diff --git a/.env.example b/.env.example index 57283ef..3edb9f1 100644 --- a/.env.example +++ b/.env.example @@ -28,4 +28,3 @@ POLAR_WEBHOOK_SECRET= POLAR_MODE= POLAR_SUCCESS_URL= ADMIN_EMAIL= -ADMIN_PASSWORD_HASH= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 73a6bfc..8133083 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,9 +48,21 @@ jobs: needs: ci if: github.ref == 'refs/heads/staging' && github.event_name == 'push' runs-on: ubuntu-latest + env: + TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} + TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }} + PROCESS_NAME: Numex API - Staging steps: - uses: actions/checkout@v4 + - name: Notify staging deploying + if: ${{ env.TELEGRAM_BOT_TOKEN != '' && env.TELEGRAM_CHAT_ID != '' }} + run: | + MESSAGE="${PROCESS_NAME}"$'\n'"Deploying" + curl -fsS -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \ + -d "chat_id=${TELEGRAM_CHAT_ID}" \ + --data-urlencode "text=${MESSAGE}" + - name: Copy source to server uses: appleboy/scp-action@v0.1.7 with: @@ -68,17 +80,46 @@ jobs: username: root key: ${{ secrets.DEPLOY_SSH_KEY }} script: | + set -e docker compose -f /opt/numex/deploy/docker-compose.yml build numex-api-staging docker compose -f /opt/numex/deploy/docker-compose.yml up -d numex-api-staging docker image prune -f + - name: Notify staging live + if: ${{ success() && env.TELEGRAM_BOT_TOKEN != '' && env.TELEGRAM_CHAT_ID != '' }} + run: | + MESSAGE="${PROCESS_NAME}"$'\n'"Live" + curl -fsS -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \ + -d "chat_id=${TELEGRAM_CHAT_ID}" \ + --data-urlencode "text=${MESSAGE}" + + - name: Notify staging failed + if: ${{ failure() && env.TELEGRAM_BOT_TOKEN != '' && env.TELEGRAM_CHAT_ID != '' }} + run: | + MESSAGE="${PROCESS_NAME}"$'\n'"Failed" + curl -fsS -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \ + -d "chat_id=${TELEGRAM_CHAT_ID}" \ + --data-urlencode "text=${MESSAGE}" + deploy-prod: needs: ci if: github.ref == 'refs/heads/main' && github.event_name == 'push' runs-on: ubuntu-latest + env: + TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} + TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }} + PROCESS_NAME: Numex API - Production steps: - uses: actions/checkout@v4 + - name: Notify prod deploying + if: ${{ env.TELEGRAM_BOT_TOKEN != '' && env.TELEGRAM_CHAT_ID != '' }} + run: | + MESSAGE="${PROCESS_NAME}"$'\n'"Deploying" + curl -fsS -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \ + -d "chat_id=${TELEGRAM_CHAT_ID}" \ + --data-urlencode "text=${MESSAGE}" + - name: Copy source to server uses: appleboy/scp-action@v0.1.7 with: @@ -96,6 +137,23 @@ jobs: username: root key: ${{ secrets.DEPLOY_SSH_KEY }} script: | + set -e docker compose -f /opt/numex/deploy/docker-compose.yml build numex-api-prod docker compose -f /opt/numex/deploy/docker-compose.yml up -d numex-api-prod docker image prune -f + + - name: Notify prod live + if: ${{ success() && env.TELEGRAM_BOT_TOKEN != '' && env.TELEGRAM_CHAT_ID != '' }} + run: | + MESSAGE="${PROCESS_NAME}"$'\n'"Live" + curl -fsS -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \ + -d "chat_id=${TELEGRAM_CHAT_ID}" \ + --data-urlencode "text=${MESSAGE}" + + - name: Notify prod failed + if: ${{ failure() && env.TELEGRAM_BOT_TOKEN != '' && env.TELEGRAM_CHAT_ID != '' }} + run: | + MESSAGE="${PROCESS_NAME}"$'\n'"Failed" + curl -fsS -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \ + -d "chat_id=${TELEGRAM_CHAT_ID}" \ + --data-urlencode "text=${MESSAGE}" diff --git a/.gitignore b/.gitignore index 4248f3e..a39d1f0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ .env +.env.local adminpasswd +.worktrees/ +tmp-api-dev*.log diff --git a/Dockerfile b/Dockerfile index 075a4ac..f36548e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ # syntax=docker/dockerfile:1 -FROM golang:1.25-alpine AS verify +FROM golang:1.25.10-alpine AS verify WORKDIR /app COPY go.mod go.sum ./ @@ -9,7 +9,7 @@ RUN go mod download COPY . . RUN go vet ./... && go build ./cmd/api -FROM golang:1.25-alpine AS builder +FROM golang:1.25.10-alpine AS builder WORKDIR /app COPY go.mod go.sum ./ diff --git a/Makefile b/Makefile index 4ee0fd6..de3c72f 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,12 @@ -DB_CONTAINER=numex_db +ENV_FILE ?= .env.local +DB_CONTAINER ?= numex_db +DB_USER ?= numex +DB_NAME ?= numex ifeq ($(OS),Windows_NT) - WAIT_CMD = @powershell -Command "Start-Sleep 2; while (!(docker exec $(DB_CONTAINER) pg_isready -U numex 2>$$null)) { Start-Sleep 1 }" + WAIT_CMD = @powershell -NoProfile -Command 'Start-Sleep 2; do { docker exec $(DB_CONTAINER) pg_isready -U $(DB_USER) -d $(DB_NAME) 2>$$null; if ($$LASTEXITCODE -ne 0) { Start-Sleep 1 } } until ($$LASTEXITCODE -eq 0)' else - WAIT_CMD = @sleep 2 && until docker exec $(DB_CONTAINER) pg_isready -U numex; do sleep 1; done + WAIT_CMD = @sleep 2 && until docker exec $(DB_CONTAINER) pg_isready -U $(DB_USER) -d $(DB_NAME); do sleep 1; done endif ifeq ($(OS),Windows_NT) @@ -14,17 +17,17 @@ endif init: @echo Starting containers... - docker compose up -d + docker compose --env-file $(ENV_FILE) up -d --wait --wait-timeout 60 @echo Giving time for PostgreSQL to start... $(WAIT_CMD) @echo Injecting schema... - docker exec -i $(DB_CONTAINER) psql -U numex -d numex < internal/db/schema.sql + docker exec -i $(DB_CONTAINER) psql -U $(DB_USER) -d $(DB_NAME) < internal/db/schema.sql @echo Seeding data... - docker exec -i $(DB_CONTAINER) psql -U numex -d numex < internal/db/data.sql + docker exec -i $(DB_CONTAINER) psql -U $(DB_USER) -d $(DB_NAME) < internal/db/data.sql @echo The database was initialized successfully. down: - docker compose down -v + docker compose --env-file $(ENV_FILE) down -v @echo Containers and data have been deleted. run: @@ -75,7 +78,7 @@ endif psql "$(DATABASE_URL)" -q; \ else \ { printf '\\set h %s\n' "$$HASH"; printf "UPDATE app_config SET value = to_json(:'h'::text) WHERE key = 'admin_password_hash';\n"; } | \ - docker exec -i $(DB_CONTAINER) psql -U numex -d numex -q; \ + docker exec -i $(DB_CONTAINER) psql -U $(DB_USER) -d $(DB_NAME) -q; \ fi && \ echo "Admin password updated successfully." diff --git a/cmd/api/server.go b/cmd/api/server.go index 1943118..5b930b2 100644 --- a/cmd/api/server.go +++ b/cmd/api/server.go @@ -4,14 +4,17 @@ import ( "context" "errors" "fmt" + "io" "log" "net/http" + "os" "os/signal" "strings" "sync" "syscall" "time" + "numex-api/internal/broadcast" "numex-api/internal/cache" "numex-api/internal/clients" "numex-api/internal/config" @@ -20,7 +23,6 @@ import ( "numex-api/internal/handlers" "numex-api/internal/middlewares" "numex-api/internal/services" - "numex-api/internal/workers" "github.com/go-playground/validator/v10" "github.com/labstack/echo/v4" @@ -78,6 +80,10 @@ func run() error { } emailService := clients.NewEmailService(configCache) + broadcaster := broadcast.NewLogBroadcaster(500) + logWriter := io.MultiWriter(os.Stdout, broadcaster) + log.SetOutput(logWriter) + s := handlers.Server{ DB: pool, Queries: q, @@ -88,34 +94,24 @@ func run() error { Payme: paymeClient, Polar: polarClient, Email: emailService, + Broadcaster: broadcaster, } if polarClient != nil && strings.Trim(strings.TrimSpace(configCache.GetString("polar_enabled", "false")), `"`) == "true" { - summary, err := handlers.SyncPolarStoreProducts(context.Background(), polarClient, q) - if err != nil { + if _, err := handlers.SyncPolarStoreProducts(context.Background(), polarClient, q); err != nil { log.Printf("polar store products: startup sync failed: %v", err) } else { - log.Printf( - "polar store products: startup sync complete created=%d updated=%d deactivated=%d skipped=%d", - summary.Created, - summary.Updated, - summary.Deactivated, - summary.Skipped, - ) + log.Printf("polar store products: startup sync completed") } } - e := setupEcho(ipExtractor) + e := setupEcho(ipExtractor, logWriter) handlers.Handlers(e, &s) ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer cancel() var wg sync.WaitGroup - billingWorker := workers.NewBillingWorker(pool, paymeClient, emailService) - billingWorker.Start(ctx, time.Minute, &wg) - downgradeCleanupWorker := workers.NewDowngradeCleanupWorker(pool) - downgradeCleanupWorker.Start(ctx, time.Minute, &wg) configCache.StartAutoRefresh(ctx, 5*time.Minute, &wg) services.StartRateFetcher(ctx, q, &wg) services.StartIdempotencyCleanup(ctx, q, &wg) @@ -125,7 +121,7 @@ func run() error { return err } -func setupEcho(ipExtractor func(*http.Request) string) *echo.Echo { +func setupEcho(ipExtractor func(*http.Request) string, logWriter io.Writer) *echo.Echo { e := echo.New() e.HideBanner = true @@ -137,9 +133,16 @@ func setupEcho(ipExtractor func(*http.Request) string) *echo.Echo { e.IPExtractor = ipExtractor e.Use(middleware.RequestID()) + e.Use(middlewares.VerboseBodyLogger(logWriter, config.EnVar.VerboseHTTPBodyLogging, config.EnVar.VerboseHTTPBodyLogLimit)) e.Use(middleware.Recover()) - e.Use(middlewares.Logger()) - e.Use(middleware.BodyLimit("1M")) + e.Use(middlewares.Logger(logWriter)) + e.Use(middleware.BodyLimitWithConfig(middleware.BodyLimitConfig{ + // Skip the voice route — it enforces its own 10 MB limit in the handler. + Skipper: func(c echo.Context) bool { + return c.Request().URL.Path == "/api/transactions/voice" + }, + Limit: "1M", + })) e.Use(middleware.RateLimiter( middleware.NewRateLimiterMemoryStoreWithConfig( middleware.RateLimiterMemoryStoreConfig{ diff --git a/compose.yaml b/compose.yaml index 45812fd..334f124 100644 --- a/compose.yaml +++ b/compose.yaml @@ -11,6 +11,12 @@ services: - "5432:5432" volumes: - postgres_data:/var/lib/postgresql + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s redis: image: redis:8.4-alpine container_name: numex_redis @@ -20,6 +26,12 @@ services: - "6379:6379" volumes: - redis_data:/data + healthcheck: + test: ["CMD-SHELL", "redis-cli -a ${REDIS_PASSWORD} ping | grep PONG"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 5s volumes: postgres_data: diff --git a/go.mod b/go.mod index 1d9bbe5..de30e6f 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module numex-api -go 1.25.9 +go 1.25.10 require ( github.com/alexedwards/argon2id v1.0.0 @@ -11,11 +11,12 @@ require ( github.com/joho/godotenv v1.5.1 github.com/kelseyhightower/envconfig v1.4.0 github.com/labstack/echo/v4 v4.15.1 + github.com/minio/minio-go/v7 v7.0.100 github.com/polarsource/polar-go v0.12.0 github.com/redis/go-redis/v9 v9.18.0 github.com/stretchr/testify v1.11.1 github.com/svix/svix-webhooks v1.89.0 - golang.org/x/crypto v0.47.0 + golang.org/x/crypto v0.51.0 golang.org/x/time v0.14.0 google.golang.org/api v0.197.0 google.golang.org/genai v1.51.0 @@ -29,8 +30,10 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/gabriel-vasile/mimetype v1.4.12 // indirect + github.com/go-ini/ini v1.67.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-playground/locales v0.14.1 // indirect @@ -43,12 +46,20 @@ require ( github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // 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/labstack/gommon v0.4.2 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/minio/crc64nvme v1.1.1 // indirect + github.com/minio/md5-simd v1.1.2 // indirect + github.com/philhofer/fwd v1.2.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rs/xid v1.6.0 // indirect github.com/spyzhov/ajson v0.8.0 // indirect + github.com/tinylib/msgp v1.6.1 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect go.opencensus.io v0.24.0 // indirect @@ -58,11 +69,12 @@ require ( go.opentelemetry.io/otel/metric v1.39.0 // indirect go.opentelemetry.io/otel/trace v1.39.0 // indirect go.uber.org/atomic v1.11.0 // indirect - golang.org/x/net v0.49.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/net v0.55.0 // indirect golang.org/x/oauth2 v0.35.0 // indirect - golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.40.0 // indirect - golang.org/x/text v0.33.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/text v0.37.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20 // indirect google.golang.org/grpc v1.79.3 // indirect google.golang.org/protobuf v1.36.11 // indirect diff --git a/go.sum b/go.sum index 7336521..7079f4e 100644 --- a/go.sum +++ b/go.sum @@ -24,6 +24,8 @@ 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/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +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/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= @@ -32,6 +34,8 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +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/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -90,12 +94,19 @@ github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo= github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jarcoal/httpmock v1.3.1 h1:iUx3whfZWVf3jT01hQTO/Eo5sAYtB2/rqaUuOtpInww= +github.com/jarcoal/httpmock v1.3.1/go.mod h1:3yb8rc4BI7TCBhFY8ng0gjuLKJNquuDNiPaZjnENuYg= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= -github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= -github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +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/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -110,6 +121,14 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +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.100 h1:ShkWi8Tyj9RtU57OQB2HIXKz4bFgtVib0bbT1sbtLI8= +github.com/minio/minio-go/v7 v7.0.100/go.mod h1:EtGNKtlX20iL2yaYnxEigaIvj0G0GwSDnifnG8ClIdw= +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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/polarsource/polar-go v0.12.0 h1:um+6ftOPUMg2TQq9Kv/6fKGBOAl7dOc2YiDdx4Bb0y8= @@ -119,6 +138,8 @@ github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfS github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/spyzhov/ajson v0.8.0 h1:sFXyMbi4Y/BKjrsfkUZHSjA2JM1184enheSjjoT/zCc= github.com/spyzhov/ajson v0.8.0/go.mod h1:63V+CGM6f1Bu/p4nLIN8885ojBdt88TbLoSFzyqMuVA= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -133,6 +154,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/svix/svix-webhooks v1.89.0 h1:X/vIg2P/gIrDVFVNNQZxEr1nDZv+QU852u+3+/qbqaE= github.com/svix/svix-webhooks v1.89.0/go.mod h1:BRbQWn/xdv6zSGULojHza0Yx+hDf+xUJ4s09t3HqJpI= +github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY= +github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= @@ -160,12 +183,14 @@ go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6 go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +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.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= -golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= -golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= @@ -183,8 +208,8 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= -golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= -golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= @@ -193,8 +218,8 @@ golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -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/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -207,8 +232,8 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -220,8 +245,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= -golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= -golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/internal/broadcast/logs.go b/internal/broadcast/logs.go new file mode 100644 index 0000000..35282c2 --- /dev/null +++ b/internal/broadcast/logs.go @@ -0,0 +1,94 @@ +package broadcast + +import ( + "sync" +) + +const defaultRingSize = 500 + +// LogBroadcaster captures log lines written to it and fans them out +// to all active SSE subscribers. It also implements io.Writer so it +// can be composed with os.Stdout via io.MultiWriter. +type LogBroadcaster struct { + mu sync.RWMutex + ring []string + head int // next write position in the ring + count int // total lines written (capped at cap(ring)) + clients map[chan string]struct{} +} + +func NewLogBroadcaster(ringSize int) *LogBroadcaster { + if ringSize <= 0 { + ringSize = defaultRingSize + } + return &LogBroadcaster{ + ring: make([]string, ringSize), + clients: make(map[chan string]struct{}), + } +} + +// Write implements io.Writer. Each call is treated as one log line. +func (b *LogBroadcaster) Write(p []byte) (int, error) { + line := string(p) + b.mu.Lock() + b.ring[b.head] = line + b.head = (b.head + 1) % len(b.ring) + if b.count < len(b.ring) { + b.count++ + } + // snapshot clients under lock to avoid holding lock during send + snapshot := make([]chan string, 0, len(b.clients)) + for ch := range b.clients { + snapshot = append(snapshot, ch) + } + b.mu.Unlock() + + for _, ch := range snapshot { + select { + case ch <- line: + default: + // slow client — drop line, never block + } + } + return len(p), nil +} + +// Subscribe registers a new client. It immediately receives all buffered +// lines (oldest first), then live lines as they arrive. +// The caller must call Unsubscribe(ch) when done (e.g. via defer). +func (b *LogBroadcaster) Subscribe() chan string { + ch := make(chan string, 256) + + b.mu.Lock() + // replay ring buffer in chronological order + size := len(b.ring) + start := 0 + if b.count == size { + // ring is full — oldest line is at b.head + start = b.head + } + lines := make([]string, 0, b.count) + for i := 0; i < b.count; i++ { + lines = append(lines, b.ring[(start+i)%size]) + } + b.clients[ch] = struct{}{} + b.mu.Unlock() + + // send buffered history without holding the lock + for _, line := range lines { + select { + case ch <- line: + default: + } + } + + return ch +} + +// Unsubscribe deregisters a client channel and closes it. +func (b *LogBroadcaster) Unsubscribe(ch chan string) { + b.mu.Lock() + delete(b.clients, ch) + b.mu.Unlock() + close(ch) +} diff --git a/internal/cache/config.go b/internal/cache/config.go index d824dd2..6c271a5 100644 --- a/internal/cache/config.go +++ b/internal/cache/config.go @@ -2,6 +2,7 @@ package cache import ( "context" + "encoding/json" "log" "numex-api/internal/db/queries" "numex-api/internal/utils" @@ -86,3 +87,16 @@ func (c *ConfigCache) StartAutoRefresh(ctx context.Context, interval time.Durati }) }) } + +func (c *ConfigCache) SetForTest(key, value string) { + c.mu.Lock() + defer c.mu.Unlock() + if c.data == nil { + c.data = make(map[string]queries.AppConfig) + } + raw, _ := json.Marshal(value) + c.data[key] = queries.AppConfig{ + Key: key, + Value: raw, + } +} diff --git a/internal/clients/gemini.go b/internal/clients/gemini.go index a0e3218..d41794f 100644 --- a/internal/clients/gemini.go +++ b/internal/clients/gemini.go @@ -3,6 +3,9 @@ package clients import ( "context" "errors" + "fmt" + "net" + "net/http" "time" "numex-api/internal/db/queries" @@ -12,16 +15,30 @@ import ( ) type GeminiClient struct { - client *genai.Client - apiKey string - queries *queries.Queries - KeyID pgtype.UUID + candidates []geminiCandidate + queries *queries.Queries + generateContent generateContentFunc + generateMultimodal generateMultimodalFunc } func (g *GeminiClient) APIKey() string { - return g.apiKey + if len(g.candidates) == 0 { + return "" + } + return g.candidates[0].apiKey } +type geminiCandidate struct { + client *genai.Client + apiKey string + keyID pgtype.UUID +} + +type generateContentFunc func(context.Context, *genai.Client, string, string, *genai.GenerateContentConfig) (*genai.GenerateContentResponse, error) +type generateMultimodalFunc func(context.Context, *genai.Client, string, []*genai.Part, *genai.GenerateContentConfig) (*genai.GenerateContentResponse, error) + +var ErrGeminiTemporarilyUnavailable = errors.New("gemini temporarily unavailable") + // BuildGeminiConfig creates a GenerateContentConfig from the database prompt settings. func (g *GeminiClient) BuildGeminiConfig(promptDb queries.AiPrompt, defaultMIMEType string) *genai.GenerateContentConfig { cfg := &genai.GenerateContentConfig{ @@ -50,43 +67,73 @@ func (g *GeminiClient) BuildGeminiConfig(promptDb queries.AiPrompt, defaultMIMET } func (g *GeminiClient) Generate(ctx context.Context, prompt string, cfg *genai.GenerateContentConfig, model string) (*genai.GenerateContentResponse, error) { - resp, err := g.client.Models.GenerateContent(ctx, model, genai.Text(prompt), cfg) - if err != nil { - return nil, err - } - - go func() { // #nosec G118 -- tracking must complete after request ends; intentional background context - trackCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - _ = g.queries.TrackAIKeyUsage(trackCtx, queries.TrackAIKeyUsageParams{ - ID: g.KeyID, - InputTokens: int64(resp.UsageMetadata.PromptTokenCount), - OutputTokens: int64(resp.UsageMetadata.CandidatesTokenCount), - TotalTokens: int64(resp.UsageMetadata.TotalTokenCount), - }) - }() - - return resp, nil + return g.generateWithFailover(ctx, func(candidate geminiCandidate) (*genai.GenerateContentResponse, error) { + return g.generateContent(ctx, candidate.client, model, prompt, cfg) + }) } func (g *GeminiClient) GenerateMultimodal(ctx context.Context, model string, parts []*genai.Part, cfg *genai.GenerateContentConfig) (*genai.GenerateContentResponse, error) { - resp, err := g.client.Models.GenerateContent(ctx, model, []*genai.Content{genai.NewContentFromParts(parts, "user")}, cfg) - if err != nil { - return nil, err + return g.generateWithFailover(ctx, func(candidate geminiCandidate) (*genai.GenerateContentResponse, error) { + return g.generateMultimodal(ctx, candidate.client, model, parts, cfg) + }) +} + +func (g *GeminiClient) generateWithFailover(ctx context.Context, attempt func(geminiCandidate) (*genai.GenerateContentResponse, error)) (*genai.GenerateContentResponse, error) { + var lastRetryableErr error + for _, candidate := range g.candidates { + resp, err := attempt(candidate) + if err == nil { + g.trackUsage(candidate.keyID, resp) + return resp, nil + } + if !isRetryableGeminiError(err) { + return nil, err + } + lastRetryableErr = err + } + if lastRetryableErr != nil { + return nil, fmt.Errorf("%w: %v", ErrGeminiTemporarilyUnavailable, lastRetryableErr) } + return nil, ErrGeminiTemporarilyUnavailable +} +func (g *GeminiClient) trackUsage(keyID pgtype.UUID, resp *genai.GenerateContentResponse) { + if g.queries == nil { + return + } go func() { // #nosec G118 -- tracking must complete after request ends; intentional background context trackCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() _ = g.queries.TrackAIKeyUsage(trackCtx, queries.TrackAIKeyUsageParams{ - ID: g.KeyID, + ID: keyID, InputTokens: int64(resp.UsageMetadata.PromptTokenCount), OutputTokens: int64(resp.UsageMetadata.CandidatesTokenCount), TotalTokens: int64(resp.UsageMetadata.TotalTokenCount), }) }() +} - return resp, nil +func isRetryableGeminiError(err error) bool { + if err == nil { + return false + } + if errors.Is(err, context.DeadlineExceeded) { + return true + } + var netErr net.Error + if errors.As(err, &netErr) && (netErr.Timeout()) { + return true + } + var apiErr genai.APIError + if errors.As(err, &apiErr) { + switch apiErr.Code { + case http.StatusUnauthorized, http.StatusForbidden, http.StatusTooManyRequests: + return true + default: + return apiErr.Code >= 500 && apiErr.Code <= 599 + } + } + return false } type GeminiFactory struct { @@ -98,23 +145,35 @@ func NewGeminiFactory(q *queries.Queries) *GeminiFactory { } func (f *GeminiFactory) CreateClient(ctx context.Context) (*GeminiClient, error) { - key, err := f.queries.GetActiveGeminiKey(ctx) - if err != nil { + keys, err := f.queries.ListEligibleGeminiKeys(ctx) + if err != nil || len(keys) == 0 { return nil, errors.New("no available gemini key") } - client, err := genai.NewClient(ctx, &genai.ClientConfig{ - APIKey: key.ApiKey, - Backend: genai.BackendGeminiAPI, - }) - if err != nil { - return nil, err + candidates := make([]geminiCandidate, 0, len(keys)) + for _, key := range keys { + client, err := genai.NewClient(ctx, &genai.ClientConfig{ + APIKey: key.ApiKey, + Backend: genai.BackendGeminiAPI, + }) + if err != nil { + return nil, err + } + candidates = append(candidates, geminiCandidate{ + client: client, + apiKey: key.ApiKey, + keyID: key.ID, + }) } return &GeminiClient{ - client: client, - apiKey: key.ApiKey, - queries: f.queries, - KeyID: key.ID, + candidates: candidates, + queries: f.queries, + generateContent: func(ctx context.Context, client *genai.Client, model string, prompt string, cfg *genai.GenerateContentConfig) (*genai.GenerateContentResponse, error) { + return client.Models.GenerateContent(ctx, model, genai.Text(prompt), cfg) + }, + generateMultimodal: func(ctx context.Context, client *genai.Client, model string, parts []*genai.Part, cfg *genai.GenerateContentConfig) (*genai.GenerateContentResponse, error) { + return client.Models.GenerateContent(ctx, model, []*genai.Content{genai.NewContentFromParts(parts, "user")}, cfg) + }, }, nil } diff --git a/internal/clients/gemini_test.go b/internal/clients/gemini_test.go new file mode 100644 index 0000000..c62a21a --- /dev/null +++ b/internal/clients/gemini_test.go @@ -0,0 +1,113 @@ +package clients + +import ( + "context" + "errors" + "net" + "net/http" + "testing" + + "github.com/jackc/pgx/v5/pgtype" + "google.golang.org/genai" +) + +func TestIsRetryableGeminiError(t *testing.T) { + tests := []struct { + name string + err error + want bool + }{ + {name: "rate limit", err: genai.APIError{Code: http.StatusTooManyRequests}, want: true}, + {name: "unauthorized key", err: genai.APIError{Code: http.StatusUnauthorized}, want: true}, + {name: "forbidden key", err: genai.APIError{Code: http.StatusForbidden}, want: true}, + {name: "upstream 5xx", err: genai.APIError{Code: http.StatusBadGateway}, want: true}, + {name: "deadline", err: context.DeadlineExceeded, want: true}, + {name: "timeout network", err: timeoutNetError{}, want: true}, + {name: "bad request", err: genai.APIError{Code: http.StatusBadRequest}, want: false}, + {name: "plain error", err: errors.New("bad prompt"), want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isRetryableGeminiError(tt.err); got != tt.want { + t.Fatalf("isRetryableGeminiError() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestGenerateFailsOverOncePerCandidate(t *testing.T) { + var attempts []string + client := &GeminiClient{ + candidates: []geminiCandidate{ + {apiKey: "key-1", keyID: pgtype.UUID{Valid: true}}, + {apiKey: "key-2", keyID: pgtype.UUID{Valid: true}}, + }, + generateContent: func(_ context.Context, _ *genai.Client, _ string, prompt string, _ *genai.GenerateContentConfig) (*genai.GenerateContentResponse, error) { + attempts = append(attempts, prompt) + if len(attempts) == 1 { + return nil, genai.APIError{Code: http.StatusTooManyRequests} + } + return &genai.GenerateContentResponse{}, nil + }, + } + + if _, err := client.Generate(context.Background(), "prompt", nil, "model"); err != nil { + t.Fatalf("Generate() error = %v", err) + } + if len(attempts) != 2 { + t.Fatalf("attempts = %d, want 2", len(attempts)) + } +} + +func TestGenerateStopsOnDeterministicError(t *testing.T) { + attempts := 0 + client := &GeminiClient{ + candidates: []geminiCandidate{ + {apiKey: "key-1"}, + {apiKey: "key-2"}, + }, + generateContent: func(context.Context, *genai.Client, string, string, *genai.GenerateContentConfig) (*genai.GenerateContentResponse, error) { + attempts++ + return nil, genai.APIError{Code: http.StatusBadRequest} + }, + } + + _, err := client.Generate(context.Background(), "prompt", nil, "model") + if err == nil { + t.Fatal("Generate() error = nil, want bad request") + } + if attempts != 1 { + t.Fatalf("attempts = %d, want 1", attempts) + } +} + +func TestGenerateReturnsTemporaryUnavailableAfterRetryablePoolExhausted(t *testing.T) { + attempts := 0 + client := &GeminiClient{ + candidates: []geminiCandidate{ + {apiKey: "key-1"}, + {apiKey: "key-2"}, + }, + generateContent: func(context.Context, *genai.Client, string, string, *genai.GenerateContentConfig) (*genai.GenerateContentResponse, error) { + attempts++ + return nil, genai.APIError{Code: http.StatusTooManyRequests} + }, + } + + _, err := client.Generate(context.Background(), "prompt", nil, "model") + if !errors.Is(err, ErrGeminiTemporarilyUnavailable) { + t.Fatalf("Generate() error = %v, want ErrGeminiTemporarilyUnavailable", err) + } + if attempts != 2 { + t.Fatalf("attempts = %d, want 2", attempts) + } +} + +type timeoutNetError struct{} + +func (timeoutNetError) Error() string { return "timeout" } +func (timeoutNetError) Timeout() bool { return true } +func (timeoutNetError) Temporary() bool { return false } + +var _ net.Error = timeoutNetError{} diff --git a/internal/clients/polar.go b/internal/clients/polar.go index 60e3d50..0d5aa28 100644 --- a/internal/clients/polar.go +++ b/internal/clients/polar.go @@ -4,9 +4,13 @@ import ( "context" "errors" "fmt" + "io" + "net/http" + "net/url" "sort" "strconv" "strings" + "time" polargo "github.com/polarsource/polar-go" "github.com/polarsource/polar-go/models/components" @@ -24,29 +28,45 @@ var ( // PolarClient wraps the Polar.sh Go SDK. type PolarClient struct { - client *polargo.Polar + client *polargo.Polar + httpClient polargo.HTTPClient + serverURL string + accessToken string } // PolarNormalizedProduct is the Numex-friendly subset of a Polar product. type PolarNormalizedProduct struct { - ProductID string - ProductName string - PlanID string - BillingPeriod string - PriceMinor int64 - CurrencyCode string + ProductID string + ProductName string + PlanID string + BillingPeriod string + PriceMinor int64 + CurrencyCode string + TrialInterval string + TrialIntervalCount int32 } // NewPolarClient creates a configured Polar client. // mode: "sandbox" uses sandbox-api.polar.sh; anything else uses production. func NewPolarClient(accessToken, mode string) *PolarClient { + accessToken = strings.TrimSpace(accessToken) + httpClient := &http.Client{Timeout: 60 * time.Second} opts := []polargo.SDKOption{ + polargo.WithClient(httpClient), polargo.WithSecurity(accessToken), } + serverURL := strings.TrimRight(polargo.ServerList[polargo.ServerProduction], "/") if mode == "sandbox" { - opts = append(opts, polargo.WithServerURL("https://sandbox-api.polar.sh")) + serverURL = "https://sandbox-api.polar.sh" + opts = append(opts, polargo.WithServerURL(serverURL)) + } + + return &PolarClient{ + client: polargo.New(opts...), + httpClient: httpClient, + serverURL: serverURL, + accessToken: accessToken, } - return &PolarClient{client: polargo.New(opts...)} } // ListProducts fetches all recurring Polar products, including archived ones so @@ -113,24 +133,43 @@ func NormalizeProduct(product components.Product) (PolarNormalizedProduct, error return PolarNormalizedProduct{}, err } + trialInterval := "" + if product.TrialInterval != nil { + trialInterval = normalizeTrialInterval(string(*product.TrialInterval)) + } + trialIntervalCount := normalizeTrialIntervalCount(product.TrialIntervalCount) + if trialInterval == "" || trialIntervalCount == 0 { + trialInterval = "" + trialIntervalCount = 0 + } + return PolarNormalizedProduct{ - ProductID: product.ID, - ProductName: product.Name, - PlanID: planID, - BillingPeriod: period, - PriceMinor: priceMinor, - CurrencyCode: currencyCode, + ProductID: product.ID, + ProductName: product.Name, + PlanID: planID, + BillingPeriod: period, + PriceMinor: priceMinor, + CurrencyCode: strings.ToUpper(strings.TrimSpace(currencyCode)), + TrialInterval: trialInterval, + TrialIntervalCount: trialIntervalCount, }, nil } // CreateCheckout creates a Polar hosted checkout session and returns the checkout URL. -func (c *PolarClient) CreateCheckout(ctx context.Context, productID, successURL, externalCustomerID string) (string, error) { +func (c *PolarClient) CreateCheckout(ctx context.Context, productID, successURL, externalCustomerID, customerEmail string) (string, error) { + if c == nil || c.client == nil { + return "", ErrPolarClientNotConfigured + } + checkoutCreate := components.CheckoutCreate{ Products: []string{productID}, SuccessURL: polargo.String(successURL), } - if strings.TrimSpace(externalCustomerID) != "" { - checkoutCreate.ExternalCustomerID = polargo.String(strings.TrimSpace(externalCustomerID)) + if externalCustomerID = strings.TrimSpace(externalCustomerID); externalCustomerID != "" { + checkoutCreate.ExternalCustomerID = polargo.String(externalCustomerID) + } + if customerEmail = strings.TrimSpace(customerEmail); customerEmail != "" { + checkoutCreate.CustomerEmail = polargo.String(customerEmail) } res, err := c.client.Checkouts.Create(ctx, checkoutCreate) @@ -146,6 +185,10 @@ func (c *PolarClient) CreateCheckout(ctx context.Context, productID, successURL, // CancelSubscription cancels an active Polar subscription immediately. func (c *PolarClient) CancelSubscription(ctx context.Context, subscriptionID string) error { + if c == nil || c.client == nil { + return ErrPolarClientNotConfigured + } + _, err := c.client.Subscriptions.Revoke(ctx, subscriptionID) if err != nil { return fmt.Errorf("polar cancel subscription: %w", err) @@ -153,6 +196,55 @@ func (c *PolarClient) CancelSubscription(ctx context.Context, subscriptionID str return nil } +// DeleteCustomerByExternalID deletes a Polar customer by Numex user ID. +// Polar Go SDK v0.12.0 exposes DeleteExternal, but not the anonymize query +// parameter, so anonymized deletion uses a narrow HTTP call with the same +// server/token/client configuration captured by NewPolarClient. +func (c *PolarClient) DeleteCustomerByExternalID(ctx context.Context, externalID string, anonymize bool) error { + if c == nil || c.client == nil { + return ErrPolarClientNotConfigured + } + + externalID = strings.TrimSpace(externalID) + if externalID == "" { + return fmt.Errorf("polar delete customer by external id: missing external id") + } + + if !anonymize { + if _, err := c.client.Customers.DeleteExternal(ctx, externalID); err != nil { + return fmt.Errorf("polar delete customer by external id: %w", err) + } + return nil + } + + if c.serverURL == "" || c.httpClient == nil || c.accessToken == "" { + return fmt.Errorf("polar delete customer by external id: anonymized delete requires http fallback config") + } + + endpoint := c.serverURL + "/v1/customers/external/" + url.PathEscape(externalID) + "?anonymize=true" + + req, err := http.NewRequestWithContext(ctx, http.MethodDelete, endpoint, nil) + if err != nil { + return fmt.Errorf("polar delete customer by external id: create request: %w", err) + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+c.accessToken) + + res, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("polar delete customer by external id: %w", err) + } + defer func() { _ = res.Body.Close() }() + + if (res.StatusCode >= 200 && res.StatusCode < 300) || res.StatusCode == http.StatusNotFound { + _, _ = io.Copy(io.Discard, res.Body) + return nil + } + + body, _ := io.ReadAll(io.LimitReader(res.Body, 4096)) + return fmt.Errorf("polar delete customer by external id: status %d: %s", res.StatusCode, strings.TrimSpace(string(body))) +} + func extractPolarPrice(product components.Product) (int64, string, error) { for _, price := range product.Prices { if price.ProductPrice != nil && price.ProductPrice.ProductPriceFixed != nil { @@ -192,13 +284,33 @@ func normalizePolarPeriod(raw string) string { } } +func normalizeTrialInterval(raw string) string { + normalized := strings.ToLower(strings.TrimSpace(raw)) + switch normalized { + case "day", "week", "month", "year": + return normalized + default: + return "" + } +} + +func normalizeTrialIntervalCount(count *int64) int32 { + if count == nil || *count <= 0 { + return 0 + } + if *count > 1000 { + return 1000 + } + return int32(*count) +} + func productIsActive(product components.Product) (bool, error) { activeRaw := metadataString(product.Metadata, "numex_active") - if strings.TrimSpace(activeRaw) == "" { + if activeRaw == "" { return true, nil } - active, err := strconv.ParseBool(strings.TrimSpace(activeRaw)) + active, err := strconv.ParseBool(activeRaw) if err != nil { return false, ErrPolarProductInactive } diff --git a/internal/clients/polar_test.go b/internal/clients/polar_test.go index cf1bab7..40767f2 100644 --- a/internal/clients/polar_test.go +++ b/internal/clients/polar_test.go @@ -85,6 +85,7 @@ func TestPolarClientCreateCheckout_SendsExternalCustomerID(t *testing.T) { "prod_monthly", "https://checkout.numex.uz/subscribe/success", "00000000-0000-0000-0000-000000000123", + "user@example.com", ) if err != nil { t.Fatalf("CreateCheckout returned error: %v", err) @@ -96,6 +97,9 @@ func TestPolarClientCreateCheckout_SendsExternalCustomerID(t *testing.T) { if gotBody["external_customer_id"] != "00000000-0000-0000-0000-000000000123" { t.Fatalf("external_customer_id = %v, want external customer id", gotBody["external_customer_id"]) } + if gotBody["customer_email"] != "user@example.com" { + t.Fatalf("customer_email = %v, want user@example.com", gotBody["customer_email"]) + } if !jsonSliceEqual(gotBody["products"], []any{"prod_monthly"}) { t.Fatalf("products = %#v, want [prod_monthly]", gotBody["products"]) } @@ -104,6 +108,78 @@ func TestPolarClientCreateCheckout_SendsExternalCustomerID(t *testing.T) { } } +func TestPolarClientDeleteCustomerByExternalID_SendsAnonymize(t *testing.T) { + externalID := "00000000-0000-0000-0000-000000000123" + var gotMethod string + var gotPath string + var gotAnonymize string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod = r.Method + gotPath = r.URL.Path + gotAnonymize = r.URL.Query().Get("anonymize") + + if auth := r.Header.Get("Authorization"); auth != "Bearer test-token" { + t.Fatalf("authorization = %q, want bearer token", auth) + } + + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + client := &PolarClient{ + client: polargo.New( + polargo.WithClient(server.Client()), + polargo.WithServerURL(server.URL), + polargo.WithSecurity("test-token"), + ), + httpClient: server.Client(), + serverURL: server.URL, + accessToken: "test-token", + } + + err := client.DeleteCustomerByExternalID(context.Background(), externalID, true) + if err != nil { + t.Fatalf("DeleteCustomerByExternalID returned error: %v", err) + } + if gotMethod != http.MethodDelete { + t.Fatalf("method = %s, want DELETE", gotMethod) + } + if gotPath != "/v1/customers/external/"+externalID { + t.Fatalf("path = %s, want /v1/customers/external/%s", gotPath, externalID) + } + if gotAnonymize != "true" { + t.Fatalf("anonymize = %q, want true", gotAnonymize) + } +} + +func TestPolarClientDeleteCustomerByExternalID_NotFoundIsSuccess(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + client := &PolarClient{ + client: polargo.New( + polargo.WithClient(server.Client()), + polargo.WithServerURL(server.URL), + polargo.WithSecurity("test-token"), + ), + httpClient: server.Client(), + serverURL: server.URL, + accessToken: "test-token", + } + + err := client.DeleteCustomerByExternalID( + context.Background(), + "00000000-0000-0000-0000-000000000123", + true, + ) + if err != nil { + t.Fatalf("DeleteCustomerByExternalID returned error for 404: %v", err) + } +} + func TestPolarClientListProducts_PaginatesAndFiltersRecurring(t *testing.T) { var requests []string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -172,6 +248,8 @@ func TestNormalizeProduct_MapsPeriodAndPricing(t *testing.T) { "numex_plan": components.CreateProductMetadataStr("pro"), "numex_period": components.CreateProductMetadataStr("quarterly"), }, + TrialInterval: components.TrialIntervalWeek.ToPointer(), + TrialIntervalCount: polargo.Int64(1), Prices: []components.Prices{ { ProductPrice: &components.ProductPrice{ @@ -207,6 +285,55 @@ func TestNormalizeProduct_MapsPeriodAndPricing(t *testing.T) { if normalized.CurrencyCode != "USD" { t.Fatalf("currency = %q, want USD", normalized.CurrencyCode) } + if normalized.TrialInterval != "week" { + t.Fatalf("trial interval = %q, want week", normalized.TrialInterval) + } + if normalized.TrialIntervalCount != 1 { + t.Fatalf("trial interval count = %d, want 1", normalized.TrialIntervalCount) + } +} + +func TestNormalizeProduct_ZerosAbsentOrInvalidTrial(t *testing.T) { + tests := []struct { + name string + trialInterval *components.TrialInterval + trialIntervalCount *int64 + }{ + { + name: "absent trial", + }, + { + name: "missing count", + trialInterval: components.TrialIntervalWeek.ToPointer(), + trialIntervalCount: nil, + }, + { + name: "invalid count", + trialInterval: components.TrialIntervalWeek.ToPointer(), + trialIntervalCount: polargo.Int64(0), + }, + { + name: "invalid interval", + trialInterval: trialIntervalPointer("hour"), + trialIntervalCount: polargo.Int64(1), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + product := validPolarProductForNormalize() + product.TrialInterval = tt.trialInterval + product.TrialIntervalCount = tt.trialIntervalCount + + normalized, err := NormalizeProduct(product) + if err != nil { + t.Fatalf("NormalizeProduct returned error: %v", err) + } + if normalized.TrialInterval != "" || normalized.TrialIntervalCount != 0 { + t.Fatalf("trial fields = %q/%d, want empty/0", normalized.TrialInterval, normalized.TrialIntervalCount) + } + }) + } } func TestNormalizeProduct_RejectsMissingMetadata(t *testing.T) { @@ -235,6 +362,37 @@ func TestNormalizeProduct_RejectsMissingMetadata(t *testing.T) { } } +func validPolarProductForNormalize() components.Product { + return components.Product{ + ID: "prod_monthly", + Name: "NUMEX Pro Monthly", + IsArchived: false, + IsRecurring: true, + Metadata: map[string]components.ProductMetadata{ + "numex_plan": components.CreateProductMetadataStr("pro"), + "numex_period": components.CreateProductMetadataStr("monthly"), + }, + Prices: []components.Prices{ + { + ProductPrice: &components.ProductPrice{ + ProductPriceFixed: &components.ProductPriceFixed{ + ID: "price_1", + IsArchived: false, + ProductID: "prod_monthly", + PriceCurrency: "USD", + PriceAmount: 499, + }, + }, + }, + }, + } +} + +func trialIntervalPointer(value string) *components.TrialInterval { + interval := components.TrialInterval(value) + return &interval +} + func TestNormalizeProduct_RejectsArchivedAndInactive(t *testing.T) { tests := []struct { name string diff --git a/internal/config/bootstrap.go b/internal/config/bootstrap.go index bec2245..82b0e5f 100644 --- a/internal/config/bootstrap.go +++ b/internal/config/bootstrap.go @@ -14,7 +14,6 @@ import ( const ( defaultAdminEmail = "admin@numex.uz" - defaultAdminHashSeed = "$argon2id$v=19$m=65536,t=3,p=4$aw0NHRWnq42297xBCdgW3w$RLSlfiCj8tlq+tyi34Mi0t3ewCUK4PxSyB9h/q4nJQQ" defaultPolarSuccessURL = "https://checkout.numex.uz/subscribe/success" ) @@ -27,16 +26,6 @@ type appConfigBootstrapSpec struct { func BootstrapAppConfig(ctx context.Context, q *queries.Queries) error { specs := []appConfigBootstrapSpec{ - { - Key: "revenuecat_webhook_secret", - Value: strings.TrimSpace(EnVar.RevenueCatWebhookSecret), - Description: "RevenueCat webhook bearer secret", - }, - { - Key: "polar_webhook_secret", - Value: strings.TrimSpace(EnVar.PolarWebhookSecret), - Description: "Polar webhook Svix secret", - }, { Key: "polar_success_url", Value: firstNonEmpty(strings.TrimSpace(EnVar.PolarSuccessURL), defaultPolarSuccessURL), @@ -48,12 +37,6 @@ func BootstrapAppConfig(ctx context.Context, q *queries.Queries) error { Description: "Admin panel login email", ReplaceSeedPlaceholder: true, }, - { - Key: "admin_password_hash", - Value: strings.TrimSpace(EnVar.AdminPasswordHash), - Description: "Admin panel password hash (argon2id)", - ReplaceSeedPlaceholder: true, - }, } for _, spec := range specs { @@ -121,8 +104,6 @@ func shouldReplaceSeedValue(spec appConfigBootstrapSpec, current string) bool { switch spec.Key { case "admin_email": return strings.EqualFold(current, defaultAdminEmail) - case "admin_password_hash": - return current == defaultAdminHashSeed default: return false } diff --git a/internal/config/env.go b/internal/config/env.go index 70ef846..7ac6fce 100644 --- a/internal/config/env.go +++ b/internal/config/env.go @@ -6,36 +6,35 @@ import ( ) type Variables struct { - Port string `envconfig:"PORT" default:"1323"` - TrustedProxyCIDRS []string `envconfig:"TRUSTED_PROXY_CIDRS" required:"true"` - GoogleClientID string `envconfig:"GOOGLE_CLIENT_ID" required:"true"` - JwtSecret string `envconfig:"JWT_SECRET" required:"true"` - PostgresUser string `envconfig:"POSTGRES_USER" required:"true"` - PostgresPassword string `envconfig:"POSTGRES_PASSWORD" required:"true"` - PostgresDB string `envconfig:"POSTGRES_DB" required:"true"` - PostgresHost string `envconfig:"POSTGRES_HOST" required:"true"` - PostgresPort string `envconfig:"POSTGRES_PORT" required:"true"` - PostgresSSLMode string `envconfig:"POSTGRES_SSL_MODE" default:"disable"` - DBMaxConns int32 `envconfig:"DB_MAX_CONNS" required:"true"` - DBMinConns int32 `envconfig:"DB_MIN_CONNS" required:"true"` - RedisHost string `envconfig:"REDIS_HOST" required:"true"` - RedisPort string `envconfig:"REDIS_PORT" required:"true"` - RedisPassword string `envconfig:"REDIS_PASSWORD" required:"true"` - RedisDB int `envconfig:"REDIS_DB" default:"0"` - ExchangeRateAPIURL string `envconfig:"EXCHANGE_RATE_API_URL" default:"https://open.er-api.com/v6/latest/"` - ExchangeRateAPIKey string `envconfig:"EXCHANGE_RATE_API_KEY"` - RateFetchIntervalHours int `envconfig:"RATE_FETCH_INTERVAL_HOURS" default:"24"` - PaymeCashboxID string `envconfig:"PAYME_CASHBOX_ID"` - PaymeCashboxKey string `envconfig:"PAYME_CASHBOX_KEY"` - PaymeTestMode bool `envconfig:"PAYME_TEST_MODE"` - // NOTE: For temporary testing without payment providers, remove `required:"true"` from the lines below. - RevenueCatWebhookSecret string `envconfig:"REVENUECAT_WEBHOOK_SECRET"` - PolarAccessToken string `envconfig:"POLAR_ACCESS_TOKEN"` - PolarWebhookSecret string `envconfig:"POLAR_WEBHOOK_SECRET"` - PolarMode string `envconfig:"POLAR_MODE"` - PolarSuccessURL string `envconfig:"POLAR_SUCCESS_URL"` - AdminEmail string `envconfig:"ADMIN_EMAIL"` - AdminPasswordHash string `envconfig:"ADMIN_PASSWORD_HASH"` + Port string `envconfig:"PORT" default:"1323"` + TrustedProxyCIDRS []string `envconfig:"TRUSTED_PROXY_CIDRS" required:"true"` + GoogleClientID string `envconfig:"GOOGLE_CLIENT_ID" required:"true"` + JwtSecret string `envconfig:"JWT_SECRET" required:"true"` + PostgresUser string `envconfig:"POSTGRES_USER" required:"true"` + PostgresPassword string `envconfig:"POSTGRES_PASSWORD" required:"true"` + PostgresDB string `envconfig:"POSTGRES_DB" required:"true"` + PostgresHost string `envconfig:"POSTGRES_HOST" required:"true"` + PostgresPort string `envconfig:"POSTGRES_PORT" required:"true"` + PostgresSSLMode string `envconfig:"POSTGRES_SSL_MODE" default:"disable"` + DBMaxConns int32 `envconfig:"DB_MAX_CONNS" required:"true"` + DBMinConns int32 `envconfig:"DB_MIN_CONNS" required:"true"` + RedisHost string `envconfig:"REDIS_HOST" required:"true"` + RedisPort string `envconfig:"REDIS_PORT" required:"true"` + RedisPassword string `envconfig:"REDIS_PASSWORD" required:"true"` + RedisDB int `envconfig:"REDIS_DB" default:"0"` + ExchangeRateAPIURL string `envconfig:"EXCHANGE_RATE_API_URL" default:"https://open.er-api.com/v6/latest/"` + ExchangeRateAPIKey string `envconfig:"EXCHANGE_RATE_API_KEY"` + RateFetchIntervalHours int `envconfig:"RATE_FETCH_INTERVAL_HOURS" default:"24"` + PaymeCashboxID string `envconfig:"PAYME_CASHBOX_ID"` + PaymeCashboxKey string `envconfig:"PAYME_CASHBOX_KEY"` + PaymeTestMode bool `envconfig:"PAYME_TEST_MODE"` + PolarAccessToken string `envconfig:"POLAR_ACCESS_TOKEN"` + PolarMode string `envconfig:"POLAR_MODE"` + PolarSuccessURL string `envconfig:"POLAR_SUCCESS_URL"` + PolarWebhookSecret string `envconfig:"POLAR_WEBHOOK_SECRET"` + AdminEmail string `envconfig:"ADMIN_EMAIL"` + VerboseHTTPBodyLogging bool `envconfig:"VERBOSE_HTTP_BODY_LOGGING" default:"false"` + VerboseHTTPBodyLogLimit int `envconfig:"VERBOSE_HTTP_BODY_LOG_LIMIT" default:"4096"` } var EnVar Variables diff --git a/internal/db/data.sql b/internal/db/data.sql index 631d88f..30380f8 100644 --- a/internal/db/data.sql +++ b/internal/db/data.sql @@ -1,6 +1,7 @@ INSERT INTO currencies (code, minor_unit, symbol) VALUES ('USD', 2, '$'), - ('RUB', 2, '₽') + ('RUB', 2, '₽'), + ('EUR', 2, '€') ON CONFLICT (code) DO NOTHING; INSERT INTO currencies (code, minor_unit) VALUES @@ -104,6 +105,7 @@ Respond with ONLY valid JSON, no markdown, no explanation. "transactions": [ ... ], "debts": [ ... ], "debt_transactions": [ ... ], + "debt_bundles": [ ... ], "language": "detected BCP-47 language code", "transcript": "verbatim transcription of the audio input" } @@ -124,7 +126,7 @@ RULES: Default to "expense". Use "income" only if the user''s meaning clearly indicates receiving or earning money. 5. CATEGORY: - Match the transaction''s meaning to the closest category from the provided list. If no category is a confident match, return null. Never guess, never use IDs not in the list. + Match the transaction''s meaning to the closest category from the provided list using both canonical_name and localized name. User speech and category labels may use different languages. Do not return null when the transaction clearly matches one provided category; return null only when the meaning is genuinely ambiguous. Use "Other" only when no provided category is a reasonable match. Never use IDs not in the list. 6. BALANCE: Match the user''s intent to the closest balance by meaning. If no confident match or not mentioned, use the balance named "Default" from the provided list. Never inherit a balance from one transaction to another. @@ -142,16 +144,15 @@ RULES: Return 0.0–1.0 per transaction based on certainty of that specific parse. Below 0.5 = amount missing or highly uncertain. Below 0.7 = some fields uncertain. Above 0.7 = confident parse. 11. DEBTS: - Detect any situation where money is owed between the user and another person — a new loan, borrowing, or debt record. Understand this from the meaning of what was said, regardless of language or phrasing. - Each new debt MUST produce both a "debts" entry AND a corresponding paired transaction in "transactions[]". If the user mentions multiple separate debts, return one entry per debt in "debts[]" and one corresponding paired transaction per debt in "transactions[]". + Create debt records ONLY when the user explicitly describes borrowing, lending, owing money, someone paying on behalf of someone else, or repayment of an existing debt. + Ordinary purchases, bills, transfers, card/cash payments, and merchant spending are NOT debts even though money is "owed" to a merchant at checkout. + If debt intent is ambiguous, return a normal transaction and leave "debts" empty. + Detect explicit debt intent from the meaning of what was said, regardless of language or phrasing. + Each new debt MUST produce both a "debts" entry and a corresponding "debt_bundles" entry. Each debt: {"counterparty": "Name", "direction": "lent"|"owed", "amount_minor": , "currency": "XXX", "note": null|"string", "confidence": 0.0-1.0} direction "lent" = user gave money out → paired transaction type = "expense". direction "owed" = user received money → paired transaction type = "income". - For each paired transaction: - - amount_minor, currency, occurred_at: same as the debt - - category: match "Debts" from the provided category list by meaning; if user explicitly names a different category, use that instead; if no match, return null - - balance: match the best fitting balance from the provided list by meaning and context; if no confident match, use the balance named "Default" from the provided list - - confidence: same as the debt confidence + When a valid debt bundle is returned, do NOT emit a standalone paired transaction for that same money movement. 12. REPAYMENTS (when open_debts context is provided): If the user describes money moving to or from someone who already has an open debt in the provided context, treat it as a repayment — return a transaction AND a "debt_transactions" entry: {"debt_counterparty": "Name", "transaction_index": , "confidence": 0.0-1.0}. If confidence < 0.7, treat it as a new debt instead. Debt repayments must NEVER be classified as regular expense/income transactions. @@ -159,7 +160,7 @@ RULES: 13. USER_CONTEXT (when provided): Use it to improve category, balance, and merchant matching. -14. RECENT_MERCHANTS (when provided, Pro users only): +14. RECENT_MERCHANTS (when provided): Prefer matching merchants from this list when audio is ambiguous. Format: [{"merchant": "...", "category": "...", "count": N}]', 'gemini-2.5-flash', 0.1, 0.8, 20, 4096, true diff --git a/internal/db/queries/db.go b/internal/db/queries/db.go index 2b5c1c7..c69f0c5 100644 --- a/internal/db/queries/db.go +++ b/internal/db/queries/db.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.30.0 +// sqlc v1.31.1 package queries diff --git a/internal/db/queries/models.go b/internal/db/queries/models.go index 0862d97..bb06700 100644 --- a/internal/db/queries/models.go +++ b/internal/db/queries/models.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.30.0 +// sqlc v1.31.1 package queries @@ -93,22 +93,6 @@ type BannedEmail struct { BannedBy *string `json:"banned_by"` } -type BillingJob struct { - ID pgtype.UUID `json:"id"` - SubscriptionID pgtype.UUID `json:"subscription_id"` - UserID pgtype.UUID `json:"user_id"` - AmountMinor int64 `json:"amount_minor"` - CurrencyCode string `json:"currency_code"` - Status string `json:"status"` - RunAt pgtype.Timestamptz `json:"run_at"` - ClaimedAt pgtype.Timestamptz `json:"claimed_at"` - CompletedAt pgtype.Timestamptz `json:"completed_at"` - ErrorMessage *string `json:"error_message"` - RetryCount int32 `json:"retry_count"` - MaxRetries int32 `json:"max_retries"` - CreatedAt pgtype.Timestamptz `json:"created_at"` -} - type Category struct { ID pgtype.UUID `json:"id"` UserID pgtype.UUID `json:"user_id"` @@ -176,20 +160,45 @@ type Debt struct { Source string `json:"source"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + OverpaidAmountMinor int64 `json:"overpaid_amount_minor"` + SyncBundleID pgtype.UUID `json:"sync_bundle_id"` } -type DowngradeCleanupJob struct { - ID pgtype.UUID `json:"id"` - UserID pgtype.UUID `json:"user_id"` - Reason string `json:"reason"` - Status string `json:"status"` - RunAt pgtype.Timestamptz `json:"run_at"` - ClaimedAt pgtype.Timestamptz `json:"claimed_at"` - CompletedAt pgtype.Timestamptz `json:"completed_at"` - ErrorMessage *string `json:"error_message"` - RetryCount int32 `json:"retry_count"` - MaxRetries int32 `json:"max_retries"` - CreatedAt pgtype.Timestamptz `json:"created_at"` +type DebtEvent struct { + ID pgtype.UUID `json:"id"` + UserID pgtype.UUID `json:"user_id"` + DebtID pgtype.UUID `json:"debt_id"` + Kind string `json:"kind"` + Direction string `json:"direction"` + AmountMinor int64 `json:"amount_minor"` + Currency string `json:"currency"` + ImpactAmountMinor int64 `json:"impact_amount_minor"` + ImpactCurrency string `json:"impact_currency"` + ExchangeRate pgtype.Numeric `json:"exchange_rate"` + ExchangeRateDate pgtype.Timestamptz `json:"exchange_rate_date"` + IncludeInAnalytics bool `json:"include_in_analytics"` + Merchant *string `json:"merchant"` + EncryptedMerchant *string `json:"encrypted_merchant"` + Note *string `json:"note"` + EncryptedNote *string `json:"encrypted_note"` + Source string `json:"source"` + IdempotencyKey *string `json:"idempotency_key"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + DeletedAt pgtype.Timestamptz `json:"deleted_at"` +} + +type DebtEventSplit struct { + ID pgtype.UUID `json:"id"` + UserID pgtype.UUID `json:"user_id"` + DebtEventID pgtype.UUID `json:"debt_event_id"` + TransactionID pgtype.UUID `json:"transaction_id"` + BalanceID pgtype.UUID `json:"balance_id"` + AmountMinor int64 `json:"amount_minor"` + Currency string `json:"currency"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + DeletedAt pgtype.Timestamptz `json:"deleted_at"` } type Entitlement struct { @@ -286,16 +295,19 @@ type RefreshToken struct { } type StoreProduct struct { - ID pgtype.UUID `json:"id"` - PlanID string `json:"plan_id"` - Provider string `json:"provider"` - StoreProductID string `json:"store_product_id"` - Period string `json:"period"` - PriceMinor *int64 `json:"price_minor"` - CurrencyCode *string `json:"currency_code"` - IsActive bool `json:"is_active"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - UpdatedAt pgtype.Timestamptz `json:"updated_at"` + ID pgtype.UUID `json:"id"` + PlanID string `json:"plan_id"` + Provider string `json:"provider"` + StoreProductID string `json:"store_product_id"` + Period string `json:"period"` + PriceMinor *int64 `json:"price_minor"` + CurrencyCode *string `json:"currency_code"` + TrialDays int32 `json:"trial_days"` + TrialInterval string `json:"trial_interval"` + TrialIntervalCount int32 `json:"trial_interval_count"` + IsActive bool `json:"is_active"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` } type StripeCustomer struct { @@ -338,28 +350,30 @@ type SupportedLanguage struct { } type Transaction struct { - ID pgtype.UUID `json:"id"` - UserID pgtype.UUID `json:"user_id"` - CategoryID pgtype.UUID `json:"category_id"` - BalanceID pgtype.UUID `json:"balance_id"` - BatchID pgtype.UUID `json:"batch_id"` - Type string `json:"type"` - Currency string `json:"currency"` - Merchant *string `json:"merchant"` - Note *string `json:"note"` - RawQuery *string `json:"raw_query"` - OccurredAt pgtype.Timestamptz `json:"occurred_at"` - Source string `json:"source"` - Version int32 `json:"version"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - UpdatedAt pgtype.Timestamptz `json:"updated_at"` - DeletedAt pgtype.Timestamptz `json:"deleted_at"` - EncryptedAmount *string `json:"encrypted_amount"` - EncryptedMerchant *string `json:"encrypted_merchant"` - EncryptedNote *string `json:"encrypted_note"` - EncryptedRawQuery *string `json:"encrypted_raw_query"` - OrphanedAt pgtype.Timestamptz `json:"orphaned_at"` - DebtID pgtype.UUID `json:"debt_id"` + ID pgtype.UUID `json:"id"` + UserID pgtype.UUID `json:"user_id"` + CategoryID pgtype.UUID `json:"category_id"` + BalanceID pgtype.UUID `json:"balance_id"` + BatchID pgtype.UUID `json:"batch_id"` + Type string `json:"type"` + Currency string `json:"currency"` + Merchant *string `json:"merchant"` + Note *string `json:"note"` + RawQuery *string `json:"raw_query"` + OccurredAt pgtype.Timestamptz `json:"occurred_at"` + Source string `json:"source"` + Version int32 `json:"version"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + DeletedAt pgtype.Timestamptz `json:"deleted_at"` + DebtEventID pgtype.UUID `json:"debt_event_id"` + IncludeInAnalytics bool `json:"include_in_analytics"` + EncryptedAmount *string `json:"encrypted_amount"` + EncryptedMerchant *string `json:"encrypted_merchant"` + EncryptedNote *string `json:"encrypted_note"` + EncryptedRawQuery *string `json:"encrypted_raw_query"` + OrphanedAt pgtype.Timestamptz `json:"orphaned_at"` + DebtID pgtype.UUID `json:"debt_id"` } type UsageCounter struct { @@ -386,6 +400,9 @@ type User struct { CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` DeletedAt pgtype.Timestamptz `json:"deleted_at"` + FinancialGoal *string `json:"financial_goal"` + MainChallenge *string `json:"main_challenge"` + ExperienceLevel *string `json:"experience_level"` PublicKey *string `json:"public_key"` EncryptedDekBackup *string `json:"encrypted_dek_backup"` EncryptedPrivkeyBackup *string `json:"encrypted_privkey_backup"` diff --git a/internal/db/queries/query.sql.go b/internal/db/queries/query.sql.go index 177ffe7..d9fb4d4 100644 --- a/internal/db/queries/query.sql.go +++ b/internal/db/queries/query.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.30.0 +// sqlc v1.31.1 // source: query.sql package queries @@ -39,25 +39,6 @@ func (q *Queries) AdminDashboardActiveSubscriptions(ctx context.Context) (int64, return active_subscriptions, err } -const adminDashboardBillingJobStats = `-- name: AdminDashboardBillingJobStats :one -SELECT - COUNT(*) FILTER (WHERE status = 'pending') AS pending, - COUNT(*) FILTER (WHERE status = 'failed') AS failed -FROM billing_jobs -` - -type AdminDashboardBillingJobStatsRow struct { - Pending int64 `json:"pending"` - Failed int64 `json:"failed"` -} - -func (q *Queries) AdminDashboardBillingJobStats(ctx context.Context) (AdminDashboardBillingJobStatsRow, error) { - row := q.db.QueryRow(ctx, adminDashboardBillingJobStats) - var i AdminDashboardBillingJobStatsRow - err := row.Scan(&i.Pending, &i.Failed) - return i, err -} - const adminDashboardMRRByProvider = `-- name: AdminDashboardMRRByProvider :many SELECT s.provider, COALESCE(SUM(sp.price_minor), 0) AS mrr_minor, @@ -170,7 +151,7 @@ UPDATE debts SET status = 'archived', updated_at = NOW() WHERE id = $1 AND user_id = $2 -RETURNING id, user_id, counterparty, encrypted_counterparty, direction, amount_minor_original, amount_minor_remaining, currency, note, encrypted_note, status, source, created_at, updated_at +RETURNING id, user_id, counterparty, encrypted_counterparty, direction, amount_minor_original, amount_minor_remaining, currency, note, encrypted_note, status, source, created_at, updated_at, overpaid_amount_minor, sync_bundle_id ` type ArchiveDebtParams struct { @@ -196,6 +177,8 @@ func (q *Queries) ArchiveDebt(ctx context.Context, arg ArchiveDebtParams) (Debt, &i.Source, &i.CreatedAt, &i.UpdatedAt, + &i.OverpaidAmountMinor, + &i.SyncBundleID, ) return i, err } @@ -240,91 +223,6 @@ func (q *Queries) CancelAccountDeletion(ctx context.Context, id pgtype.UUID) err return err } -const claimPendingBillingJobs = `-- name: ClaimPendingBillingJobs :many -UPDATE billing_jobs SET status = 'claimed', claimed_at = now() -WHERE status = 'pending' AND run_at <= now() -RETURNING id, subscription_id, user_id, amount_minor, currency_code, status, run_at, claimed_at, completed_at, error_message, retry_count, max_retries, created_at -` - -func (q *Queries) ClaimPendingBillingJobs(ctx context.Context) ([]BillingJob, error) { - rows, err := q.db.Query(ctx, claimPendingBillingJobs) - if err != nil { - return nil, err - } - defer rows.Close() - var items []BillingJob - for rows.Next() { - var i BillingJob - if err := rows.Scan( - &i.ID, - &i.SubscriptionID, - &i.UserID, - &i.AmountMinor, - &i.CurrencyCode, - &i.Status, - &i.RunAt, - &i.ClaimedAt, - &i.CompletedAt, - &i.ErrorMessage, - &i.RetryCount, - &i.MaxRetries, - &i.CreatedAt, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const claimPendingDowngradeCleanupJobs = `-- name: ClaimPendingDowngradeCleanupJobs :many -UPDATE downgrade_cleanup_jobs -SET status = 'claimed', claimed_at = now() -WHERE id IN ( - SELECT id - FROM downgrade_cleanup_jobs - WHERE status = 'pending' AND run_at <= now() - ORDER BY run_at ASC - FOR UPDATE SKIP LOCKED -) -RETURNING id, user_id, reason, status, run_at, claimed_at, completed_at, error_message, retry_count, max_retries, created_at -` - -func (q *Queries) ClaimPendingDowngradeCleanupJobs(ctx context.Context) ([]DowngradeCleanupJob, error) { - rows, err := q.db.Query(ctx, claimPendingDowngradeCleanupJobs) - if err != nil { - return nil, err - } - defer rows.Close() - var items []DowngradeCleanupJob - for rows.Next() { - var i DowngradeCleanupJob - if err := rows.Scan( - &i.ID, - &i.UserID, - &i.Reason, - &i.Status, - &i.RunAt, - &i.ClaimedAt, - &i.CompletedAt, - &i.ErrorMessage, - &i.RetryCount, - &i.MaxRetries, - &i.CreatedAt, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - const cleanupExpiredIdempotencyKeys = `-- name: CleanupExpiredIdempotencyKeys :exec DELETE FROM idempotency_keys WHERE expires_at <= CURRENT_TIMESTAMP ` @@ -351,23 +249,21 @@ func (q *Queries) ClearUserDowngradeNotice(ctx context.Context, arg ClearUserDow return err } -const completeBillingJob = `-- name: CompleteBillingJob :exec -UPDATE billing_jobs SET status = 'completed', completed_at = now() WHERE id = $1 -` - -func (q *Queries) CompleteBillingJob(ctx context.Context, id pgtype.UUID) error { - _, err := q.db.Exec(ctx, completeBillingJob, id) - return err -} - -const completeDowngradeCleanupJob = `-- name: CompleteDowngradeCleanupJob :exec -UPDATE downgrade_cleanup_jobs -SET status = 'completed', completed_at = now() +const clearUserEncryptionStateForStartFresh = `-- name: ClearUserEncryptionStateForStartFresh :exec +UPDATE users +SET public_key = NULL, + encrypted_dek_backup = NULL, + encrypted_privkey_backup = NULL, + key_created_at = NULL, + has_encryption_keys = FALSE, + encrypted_context = NULL, + encrypted_context_summary = NULL, + updated_at = NOW() WHERE id = $1 ` -func (q *Queries) CompleteDowngradeCleanupJob(ctx context.Context, id pgtype.UUID) error { - _, err := q.db.Exec(ctx, completeDowngradeCleanupJob, id) +func (q *Queries) ClearUserEncryptionStateForStartFresh(ctx context.Context, id pgtype.UUID) error { + _, err := q.db.Exec(ctx, clearUserEncryptionStateForStartFresh, id) return err } @@ -552,20 +448,27 @@ func (q *Queries) CreateAppConfig(ctx context.Context, arg CreateAppConfigParams } const createBalance = `-- name: CreateBalance :one -INSERT INTO balances (user_id, name, description, currency, initial_amount_minor, color_token, is_system, sort_order) -VALUES ($1, $2, $3, $4, $5, $6, $7, $8) +INSERT INTO balances ( + user_id, name, description, currency, initial_amount_minor, + color_token, is_system, sort_order, + encrypted_name, encrypted_description, encrypted_initial_amount +) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING id, user_id, name, description, currency, initial_amount_minor, color_token, is_system, sort_order, created_at, updated_at, deleted_at, encrypted_name, encrypted_description, encrypted_initial_amount, encrypted_balance_snapshot, snapshot_tx_count, snapshot_updated_at, orphaned_at, is_archived, archive_reason, archived_at, archive_expires_at ` type CreateBalanceParams struct { - UserID pgtype.UUID `json:"user_id"` - Name string `json:"name"` - Description *string `json:"description"` - Currency string `json:"currency"` - InitialAmountMinor int64 `json:"initial_amount_minor"` - ColorToken string `json:"color_token"` - IsSystem bool `json:"is_system"` - SortOrder int32 `json:"sort_order"` + UserID pgtype.UUID `json:"user_id"` + Name string `json:"name"` + Description *string `json:"description"` + Currency string `json:"currency"` + InitialAmountMinor int64 `json:"initial_amount_minor"` + ColorToken string `json:"color_token"` + IsSystem bool `json:"is_system"` + SortOrder int32 `json:"sort_order"` + EncryptedName *string `json:"encrypted_name"` + EncryptedDescription *string `json:"encrypted_description"` + EncryptedInitialAmount *string `json:"encrypted_initial_amount"` } func (q *Queries) CreateBalance(ctx context.Context, arg CreateBalanceParams) (Balance, error) { @@ -578,6 +481,9 @@ func (q *Queries) CreateBalance(ctx context.Context, arg CreateBalanceParams) (B arg.ColorToken, arg.IsSystem, arg.SortOrder, + arg.EncryptedName, + arg.EncryptedDescription, + arg.EncryptedInitialAmount, ) var i Balance err := row.Scan( @@ -608,50 +514,6 @@ func (q *Queries) CreateBalance(ctx context.Context, arg CreateBalanceParams) (B return i, err } -const createBillingJob = `-- name: CreateBillingJob :one - -INSERT INTO billing_jobs (subscription_id, user_id, amount_minor, currency_code, run_at) -VALUES ($1, $2, $3, $4, $5) RETURNING id, subscription_id, user_id, amount_minor, currency_code, status, run_at, claimed_at, completed_at, error_message, retry_count, max_retries, created_at -` - -type CreateBillingJobParams struct { - SubscriptionID pgtype.UUID `json:"subscription_id"` - UserID pgtype.UUID `json:"user_id"` - AmountMinor int64 `json:"amount_minor"` - CurrencyCode string `json:"currency_code"` - RunAt pgtype.Timestamptz `json:"run_at"` -} - -// ============================================================ -// Billing Job Queries -// ============================================================ -func (q *Queries) CreateBillingJob(ctx context.Context, arg CreateBillingJobParams) (BillingJob, error) { - row := q.db.QueryRow(ctx, createBillingJob, - arg.SubscriptionID, - arg.UserID, - arg.AmountMinor, - arg.CurrencyCode, - arg.RunAt, - ) - var i BillingJob - err := row.Scan( - &i.ID, - &i.SubscriptionID, - &i.UserID, - &i.AmountMinor, - &i.CurrencyCode, - &i.Status, - &i.RunAt, - &i.ClaimedAt, - &i.CompletedAt, - &i.ErrorMessage, - &i.RetryCount, - &i.MaxRetries, - &i.CreatedAt, - ) - return i, err -} - const createCategory = `-- name: CreateCategory :one INSERT INTO categories (user_id, title, icon_id, color_token, sort_order) VALUES ($1, $2, $3, $4, $5) @@ -755,7 +617,7 @@ INSERT INTO debts ( ) VALUES ( $1, $2, $3, $4, $5, $5, $6, $7, $8, $9 ) -RETURNING id, user_id, counterparty, encrypted_counterparty, direction, amount_minor_original, amount_minor_remaining, currency, note, encrypted_note, status, source, created_at, updated_at +RETURNING id, user_id, counterparty, encrypted_counterparty, direction, amount_minor_original, amount_minor_remaining, currency, note, encrypted_note, status, source, created_at, updated_at, overpaid_amount_minor, sync_bundle_id ` type CreateDebtParams struct { @@ -801,37 +663,213 @@ func (q *Queries) CreateDebt(ctx context.Context, arg CreateDebtParams) (Debt, e &i.Source, &i.CreatedAt, &i.UpdatedAt, + &i.OverpaidAmountMinor, + &i.SyncBundleID, ) return i, err } -const createDowngradeCleanupJob = `-- name: CreateDowngradeCleanupJob :one -INSERT INTO downgrade_cleanup_jobs (user_id, reason, run_at) -VALUES ($1, $2, $3) -RETURNING id, user_id, reason, status, run_at, claimed_at, completed_at, error_message, retry_count, max_retries, created_at +const createDebtBundleTransaction = `-- name: CreateDebtBundleTransaction :one +INSERT INTO transactions ( + user_id, category_id, balance_id, type, currency, merchant, note, raw_query, + encrypted_amount, encrypted_merchant, encrypted_note, encrypted_raw_query, + occurred_at, source, debt_id, debt_event_id, include_in_analytics +) +VALUES ( + $1, $2, $3, $4, $5, + $6, $7, $8, + $9, $10, $11, $12, + NOW(), $13, $14, $15, $16 +) +RETURNING id, user_id, category_id, balance_id, batch_id, type, currency, merchant, note, raw_query, occurred_at, source, version, created_at, updated_at, deleted_at, debt_event_id, include_in_analytics, encrypted_amount, encrypted_merchant, encrypted_note, encrypted_raw_query, orphaned_at, debt_id ` -type CreateDowngradeCleanupJobParams struct { - UserID pgtype.UUID `json:"user_id"` - Reason string `json:"reason"` - RunAt pgtype.Timestamptz `json:"run_at"` +type CreateDebtBundleTransactionParams struct { + UserID pgtype.UUID `json:"user_id"` + CategoryID pgtype.UUID `json:"category_id"` + BalanceID pgtype.UUID `json:"balance_id"` + Type string `json:"type"` + Currency string `json:"currency"` + Merchant *string `json:"merchant"` + Note *string `json:"note"` + RawQuery *string `json:"raw_query"` + EncryptedAmount *string `json:"encrypted_amount"` + EncryptedMerchant *string `json:"encrypted_merchant"` + EncryptedNote *string `json:"encrypted_note"` + EncryptedRawQuery *string `json:"encrypted_raw_query"` + Source string `json:"source"` + DebtID pgtype.UUID `json:"debt_id"` + DebtEventID pgtype.UUID `json:"debt_event_id"` + IncludeInAnalytics bool `json:"include_in_analytics"` +} + +func (q *Queries) CreateDebtBundleTransaction(ctx context.Context, arg CreateDebtBundleTransactionParams) (Transaction, error) { + row := q.db.QueryRow(ctx, createDebtBundleTransaction, + arg.UserID, + arg.CategoryID, + arg.BalanceID, + arg.Type, + arg.Currency, + arg.Merchant, + arg.Note, + arg.RawQuery, + arg.EncryptedAmount, + arg.EncryptedMerchant, + arg.EncryptedNote, + arg.EncryptedRawQuery, + arg.Source, + arg.DebtID, + arg.DebtEventID, + arg.IncludeInAnalytics, + ) + var i Transaction + err := row.Scan( + &i.ID, + &i.UserID, + &i.CategoryID, + &i.BalanceID, + &i.BatchID, + &i.Type, + &i.Currency, + &i.Merchant, + &i.Note, + &i.RawQuery, + &i.OccurredAt, + &i.Source, + &i.Version, + &i.CreatedAt, + &i.UpdatedAt, + &i.DeletedAt, + &i.DebtEventID, + &i.IncludeInAnalytics, + &i.EncryptedAmount, + &i.EncryptedMerchant, + &i.EncryptedNote, + &i.EncryptedRawQuery, + &i.OrphanedAt, + &i.DebtID, + ) + return i, err +} + +const createDebtEvent = `-- name: CreateDebtEvent :one +INSERT INTO debt_events ( + user_id, debt_id, kind, direction, amount_minor, currency, + impact_amount_minor, impact_currency, exchange_rate, exchange_rate_date, + include_in_analytics, merchant, encrypted_merchant, note, encrypted_note, + source, idempotency_key +) VALUES ( + $1, $2, $3, $4, $5, $6, + $7, $8, $9, $10, + $11, $12, $13, $14, $15, + $16, $17 +) RETURNING id, user_id, debt_id, kind, direction, amount_minor, currency, impact_amount_minor, impact_currency, exchange_rate, exchange_rate_date, include_in_analytics, merchant, encrypted_merchant, note, encrypted_note, source, idempotency_key, created_at, updated_at, deleted_at +` + +type CreateDebtEventParams struct { + UserID pgtype.UUID `json:"user_id"` + DebtID pgtype.UUID `json:"debt_id"` + Kind string `json:"kind"` + Direction string `json:"direction"` + AmountMinor int64 `json:"amount_minor"` + Currency string `json:"currency"` + ImpactAmountMinor int64 `json:"impact_amount_minor"` + ImpactCurrency string `json:"impact_currency"` + ExchangeRate pgtype.Numeric `json:"exchange_rate"` + ExchangeRateDate pgtype.Timestamptz `json:"exchange_rate_date"` + IncludeInAnalytics bool `json:"include_in_analytics"` + Merchant *string `json:"merchant"` + EncryptedMerchant *string `json:"encrypted_merchant"` + Note *string `json:"note"` + EncryptedNote *string `json:"encrypted_note"` + Source string `json:"source"` + IdempotencyKey *string `json:"idempotency_key"` +} + +func (q *Queries) CreateDebtEvent(ctx context.Context, arg CreateDebtEventParams) (DebtEvent, error) { + row := q.db.QueryRow(ctx, createDebtEvent, + arg.UserID, + arg.DebtID, + arg.Kind, + arg.Direction, + arg.AmountMinor, + arg.Currency, + arg.ImpactAmountMinor, + arg.ImpactCurrency, + arg.ExchangeRate, + arg.ExchangeRateDate, + arg.IncludeInAnalytics, + arg.Merchant, + arg.EncryptedMerchant, + arg.Note, + arg.EncryptedNote, + arg.Source, + arg.IdempotencyKey, + ) + var i DebtEvent + err := row.Scan( + &i.ID, + &i.UserID, + &i.DebtID, + &i.Kind, + &i.Direction, + &i.AmountMinor, + &i.Currency, + &i.ImpactAmountMinor, + &i.ImpactCurrency, + &i.ExchangeRate, + &i.ExchangeRateDate, + &i.IncludeInAnalytics, + &i.Merchant, + &i.EncryptedMerchant, + &i.Note, + &i.EncryptedNote, + &i.Source, + &i.IdempotencyKey, + &i.CreatedAt, + &i.UpdatedAt, + &i.DeletedAt, + ) + return i, err +} + +const createDebtEventSplit = `-- name: CreateDebtEventSplit :one +INSERT INTO debt_event_splits ( + user_id, debt_event_id, transaction_id, balance_id, amount_minor, currency +) VALUES ($1, $2, $3, $4, $5, $6) +RETURNING id, user_id, debt_event_id, transaction_id, balance_id, amount_minor, currency, created_at, updated_at, deleted_at +` + +type CreateDebtEventSplitParams struct { + UserID pgtype.UUID `json:"user_id"` + DebtEventID pgtype.UUID `json:"debt_event_id"` + TransactionID pgtype.UUID `json:"transaction_id"` + BalanceID pgtype.UUID `json:"balance_id"` + AmountMinor int64 `json:"amount_minor"` + Currency string `json:"currency"` } -func (q *Queries) CreateDowngradeCleanupJob(ctx context.Context, arg CreateDowngradeCleanupJobParams) (DowngradeCleanupJob, error) { - row := q.db.QueryRow(ctx, createDowngradeCleanupJob, arg.UserID, arg.Reason, arg.RunAt) - var i DowngradeCleanupJob +func (q *Queries) CreateDebtEventSplit(ctx context.Context, arg CreateDebtEventSplitParams) (DebtEventSplit, error) { + row := q.db.QueryRow(ctx, createDebtEventSplit, + arg.UserID, + arg.DebtEventID, + arg.TransactionID, + arg.BalanceID, + arg.AmountMinor, + arg.Currency, + ) + var i DebtEventSplit err := row.Scan( &i.ID, &i.UserID, - &i.Reason, - &i.Status, - &i.RunAt, - &i.ClaimedAt, - &i.CompletedAt, - &i.ErrorMessage, - &i.RetryCount, - &i.MaxRetries, + &i.DebtEventID, + &i.TransactionID, + &i.BalanceID, + &i.AmountMinor, + &i.Currency, &i.CreatedAt, + &i.UpdatedAt, + &i.DeletedAt, ) return i, err } @@ -1215,7 +1253,7 @@ INSERT INTO transactions ( occurred_at, source, balance_id ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) -RETURNING id, user_id, category_id, balance_id, batch_id, type, currency, merchant, note, raw_query, occurred_at, source, version, created_at, updated_at, deleted_at, encrypted_amount, encrypted_merchant, encrypted_note, encrypted_raw_query, orphaned_at, debt_id +RETURNING id, user_id, category_id, balance_id, batch_id, type, currency, merchant, note, raw_query, occurred_at, source, version, created_at, updated_at, deleted_at, debt_event_id, include_in_analytics, encrypted_amount, encrypted_merchant, encrypted_note, encrypted_raw_query, orphaned_at, debt_id ` type CreateTransactionParams struct { @@ -1270,6 +1308,8 @@ func (q *Queries) CreateTransaction(ctx context.Context, arg CreateTransactionPa &i.CreatedAt, &i.UpdatedAt, &i.DeletedAt, + &i.DebtEventID, + &i.IncludeInAnalytics, &i.EncryptedAmount, &i.EncryptedMerchant, &i.EncryptedNote, @@ -1286,7 +1326,7 @@ INSERT INTO transactions (user_id, category_id, type, currency, merchant, note, encrypted_amount, encrypted_merchant, encrypted_note, encrypted_raw_query, occurred_at, source, balance_id, batch_id) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) -RETURNING id, user_id, category_id, balance_id, batch_id, type, currency, merchant, note, raw_query, occurred_at, source, version, created_at, updated_at, deleted_at, encrypted_amount, encrypted_merchant, encrypted_note, encrypted_raw_query, orphaned_at, debt_id +RETURNING id, user_id, category_id, balance_id, batch_id, type, currency, merchant, note, raw_query, occurred_at, source, version, created_at, updated_at, deleted_at, debt_event_id, include_in_analytics, encrypted_amount, encrypted_merchant, encrypted_note, encrypted_raw_query, orphaned_at, debt_id ` type CreateTransactionWithBatchParams struct { @@ -1346,6 +1386,8 @@ func (q *Queries) CreateTransactionWithBatch(ctx context.Context, arg CreateTran &i.CreatedAt, &i.UpdatedAt, &i.DeletedAt, + &i.DebtEventID, + &i.IncludeInAnalytics, &i.EncryptedAmount, &i.EncryptedMerchant, &i.EncryptedNote, @@ -1430,7 +1472,7 @@ SET is_active = false, WHERE provider = $1 AND is_active = true AND store_product_id <> ALL($2::text[]) -RETURNING id, plan_id, provider, store_product_id, period, price_minor, currency_code, is_active, created_at, updated_at +RETURNING id, plan_id, provider, store_product_id, period, price_minor, currency_code, trial_days, trial_interval, trial_interval_count, is_active, created_at, updated_at ` type DeactivateStoreProductsByProviderAndProductIDsParams struct { @@ -1455,6 +1497,9 @@ func (q *Queries) DeactivateStoreProductsByProviderAndProductIDs(ctx context.Con &i.Period, &i.PriceMinor, &i.CurrencyCode, + &i.TrialDays, + &i.TrialInterval, + &i.TrialIntervalCount, &i.IsActive, &i.CreatedAt, &i.UpdatedAt, @@ -1511,6 +1556,15 @@ func (q *Queries) DeleteBalance(ctx context.Context, arg DeleteBalanceParams) er return err } +const deleteBalancesForStartFresh = `-- name: DeleteBalancesForStartFresh :exec +DELETE FROM balances WHERE user_id = $1 +` + +func (q *Queries) DeleteBalancesForStartFresh(ctx context.Context, userID pgtype.UUID) error { + _, err := q.db.Exec(ctx, deleteBalancesForStartFresh, userID) + return err +} + const deleteCategory = `-- name: DeleteCategory :exec UPDATE categories SET deleted_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL @@ -1535,6 +1589,42 @@ func (q *Queries) DeleteContextTemplate(ctx context.Context, id pgtype.UUID) err return err } +const deleteDebtEventSplitsForStartFresh = `-- name: DeleteDebtEventSplitsForStartFresh :exec +DELETE FROM debt_event_splits WHERE user_id = $1 +` + +func (q *Queries) DeleteDebtEventSplitsForStartFresh(ctx context.Context, userID pgtype.UUID) error { + _, err := q.db.Exec(ctx, deleteDebtEventSplitsForStartFresh, userID) + return err +} + +const deleteDebtEventsForStartFresh = `-- name: DeleteDebtEventsForStartFresh :exec +DELETE FROM debt_events WHERE user_id = $1 +` + +func (q *Queries) DeleteDebtEventsForStartFresh(ctx context.Context, userID pgtype.UUID) error { + _, err := q.db.Exec(ctx, deleteDebtEventsForStartFresh, userID) + return err +} + +const deleteDebtsForStartFresh = `-- name: DeleteDebtsForStartFresh :exec +DELETE FROM debts WHERE user_id = $1 +` + +func (q *Queries) DeleteDebtsForStartFresh(ctx context.Context, userID pgtype.UUID) error { + _, err := q.db.Exec(ctx, deleteDebtsForStartFresh, userID) + return err +} + +const deleteEntitlementByUserID = `-- name: DeleteEntitlementByUserID :exec +DELETE FROM entitlements WHERE user_id = $1 +` + +func (q *Queries) DeleteEntitlementByUserID(ctx context.Context, userID pgtype.UUID) error { + _, err := q.db.Exec(ctx, deleteEntitlementByUserID, userID) + return err +} + const deleteExchangeRate = `-- name: DeleteExchangeRate :exec DELETE FROM exchange_rates WHERE base_currency = $1 AND target_currency = $2 @@ -1559,6 +1649,15 @@ func (q *Queries) DeleteExpiredAdminSessions(ctx context.Context) error { return err } +const deletePaymeCardsByUserID = `-- name: DeletePaymeCardsByUserID :exec +DELETE FROM payme_cards WHERE user_id = $1 +` + +func (q *Queries) DeletePaymeCardsByUserID(ctx context.Context, userID pgtype.UUID) error { + _, err := q.db.Exec(ctx, deletePaymeCardsByUserID, userID) + return err +} + const deletePromptVersion = `-- name: DeletePromptVersion :exec DELETE FROM ai_prompts WHERE name = $1 AND version = $2 ` @@ -1573,6 +1672,15 @@ func (q *Queries) DeletePromptVersion(ctx context.Context, arg DeletePromptVersi return err } +const deletePurchasesByUserID = `-- name: DeletePurchasesByUserID :exec +DELETE FROM purchases WHERE user_id = $1 +` + +func (q *Queries) DeletePurchasesByUserID(ctx context.Context, userID pgtype.UUID) error { + _, err := q.db.Exec(ctx, deletePurchasesByUserID, userID) + return err +} + const deleteRefreshTokenByID = `-- name: DeleteRefreshTokenByID :exec DELETE FROM refresh_tokens WHERE id = $1 @@ -1583,6 +1691,24 @@ func (q *Queries) DeleteRefreshTokenByID(ctx context.Context, id pgtype.UUID) er return err } +const deleteStripeCustomerByUserID = `-- name: DeleteStripeCustomerByUserID :exec +DELETE FROM stripe_customers WHERE user_id = $1 +` + +func (q *Queries) DeleteStripeCustomerByUserID(ctx context.Context, userID pgtype.UUID) error { + _, err := q.db.Exec(ctx, deleteStripeCustomerByUserID, userID) + return err +} + +const deleteSubscriptionsByUserID = `-- name: DeleteSubscriptionsByUserID :exec +DELETE FROM subscriptions WHERE user_id = $1 +` + +func (q *Queries) DeleteSubscriptionsByUserID(ctx context.Context, userID pgtype.UUID) error { + _, err := q.db.Exec(ctx, deleteSubscriptionsByUserID, userID) + return err +} + const deleteSupportedLanguage = `-- name: DeleteSupportedLanguage :exec DELETE FROM supported_languages WHERE code = $1 AND is_deletable = true @@ -1618,6 +1744,46 @@ func (q *Queries) DeleteTransaction(ctx context.Context, arg DeleteTransactionPa return err } +const deleteTransactionsForStartFresh = `-- name: DeleteTransactionsForStartFresh :exec + +DELETE FROM transactions WHERE user_id = $1 +` + +// ============================================================ +// Start Fresh private-data reset queries +// ============================================================ +func (q *Queries) DeleteTransactionsForStartFresh(ctx context.Context, userID pgtype.UUID) error { + _, err := q.db.Exec(ctx, deleteTransactionsForStartFresh, userID) + return err +} + +const deleteUsageCountersForStartFresh = `-- name: DeleteUsageCountersForStartFresh :exec +DELETE FROM usage_counters WHERE user_id = $1 +` + +func (q *Queries) DeleteUsageCountersForStartFresh(ctx context.Context, userID pgtype.UUID) error { + _, err := q.db.Exec(ctx, deleteUsageCountersForStartFresh, userID) + return err +} + +const deleteUserCategoriesForStartFresh = `-- name: DeleteUserCategoriesForStartFresh :exec +DELETE FROM categories WHERE user_id = $1 +` + +func (q *Queries) DeleteUserCategoriesForStartFresh(ctx context.Context, userID pgtype.UUID) error { + _, err := q.db.Exec(ctx, deleteUserCategoriesForStartFresh, userID) + return err +} + +const deleteUserForAccountDeletion = `-- name: DeleteUserForAccountDeletion :exec +DELETE FROM users WHERE id = $1 +` + +func (q *Queries) DeleteUserForAccountDeletion(ctx context.Context, id pgtype.UUID) error { + _, err := q.db.Exec(ctx, deleteUserForAccountDeletion, id) + return err +} + const encryptBalanceFields = `-- name: EncryptBalanceFields :exec UPDATE balances SET encrypted_name = $2, @@ -1664,42 +1830,6 @@ func (q *Queries) EncryptCategoryTitle(ctx context.Context, arg EncryptCategoryT return err } -const failBillingJob = `-- name: FailBillingJob :exec -UPDATE billing_jobs SET status = CASE WHEN retry_count + 1 >= max_retries THEN 'exhausted' ELSE 'failed' END, - retry_count = retry_count + 1, error_message = $2, - run_at = CASE WHEN retry_count + 1 >= max_retries THEN run_at ELSE now() + (interval '1 hour' * power(2, retry_count)) END -WHERE id = $1 -` - -type FailBillingJobParams struct { - ID pgtype.UUID `json:"id"` - ErrorMessage *string `json:"error_message"` -} - -func (q *Queries) FailBillingJob(ctx context.Context, arg FailBillingJobParams) error { - _, err := q.db.Exec(ctx, failBillingJob, arg.ID, arg.ErrorMessage) - return err -} - -const failDowngradeCleanupJob = `-- name: FailDowngradeCleanupJob :exec -UPDATE downgrade_cleanup_jobs -SET status = CASE WHEN retry_count + 1 >= max_retries THEN 'failed' ELSE 'pending' END, - retry_count = retry_count + 1, - error_message = $2, - run_at = CASE WHEN retry_count + 1 >= max_retries THEN run_at ELSE now() + interval '5 minutes' END -WHERE id = $1 -` - -type FailDowngradeCleanupJobParams struct { - ID pgtype.UUID `json:"id"` - ErrorMessage *string `json:"error_message"` -} - -func (q *Queries) FailDowngradeCleanupJob(ctx context.Context, arg FailDowngradeCleanupJobParams) error { - _, err := q.db.Exec(ctx, failDowngradeCleanupJob, arg.ID, arg.ErrorMessage) - return err -} - const getActiveBalancesOverLimitByUserID = `-- name: GetActiveBalancesOverLimitByUserID :many SELECT id, user_id, name, description, currency, initial_amount_minor, color_token, is_system, sort_order, created_at, updated_at, deleted_at, encrypted_name, encrypted_description, encrypted_initial_amount, encrypted_balance_snapshot, snapshot_tx_count, snapshot_updated_at, orphaned_at, is_archived, archive_reason, archived_at, archive_expires_at FROM balances @@ -1858,7 +1988,7 @@ func (q *Queries) GetActivePromptByName(ctx context.Context, name string) (AiPro } const getActiveStoreProductsByProvider = `-- name: GetActiveStoreProductsByProvider :many -SELECT id, plan_id, provider, store_product_id, period, price_minor, currency_code, is_active, created_at, updated_at FROM store_products WHERE provider = $1 AND is_active = true ORDER BY period +SELECT id, plan_id, provider, store_product_id, period, price_minor, currency_code, trial_days, trial_interval, trial_interval_count, is_active, created_at, updated_at FROM store_products WHERE provider = $1 AND is_active = true ORDER BY period ` func (q *Queries) GetActiveStoreProductsByProvider(ctx context.Context, provider string) ([]StoreProduct, error) { @@ -1878,6 +2008,9 @@ func (q *Queries) GetActiveStoreProductsByProvider(ctx context.Context, provider &i.Period, &i.PriceMinor, &i.CurrencyCode, + &i.TrialDays, + &i.TrialInterval, + &i.TrialIntervalCount, &i.IsActive, &i.CreatedAt, &i.UpdatedAt, @@ -2050,7 +2183,7 @@ func (q *Queries) GetAllActivePrompts(ctx context.Context) ([]AiPrompt, error) { } const getAllActiveStoreProducts = `-- name: GetAllActiveStoreProducts :many -SELECT id, plan_id, provider, store_product_id, period, price_minor, currency_code, is_active, created_at, updated_at FROM store_products WHERE is_active = true ORDER BY plan_id, period, provider +SELECT id, plan_id, provider, store_product_id, period, price_minor, currency_code, trial_days, trial_interval, trial_interval_count, is_active, created_at, updated_at FROM store_products WHERE is_active = true ORDER BY plan_id, period, provider ` func (q *Queries) GetAllActiveStoreProducts(ctx context.Context) ([]StoreProduct, error) { @@ -2070,6 +2203,9 @@ func (q *Queries) GetAllActiveStoreProducts(ctx context.Context) ([]StoreProduct &i.Period, &i.PriceMinor, &i.CurrencyCode, + &i.TrialDays, + &i.TrialInterval, + &i.TrialIntervalCount, &i.IsActive, &i.CreatedAt, &i.UpdatedAt, @@ -3137,8 +3273,28 @@ func (q *Queries) GetCurrencyByCode(ctx context.Context, code string) (Currency, return i, err } +const getDebtBundleCategoryID = `-- name: GetDebtBundleCategoryID :one +SELECT id +FROM categories +WHERE (user_id = $1 OR user_id IS NULL) + AND deleted_at IS NULL + AND title = 'Debts' +ORDER BY + CASE WHEN user_id IS NULL THEN 0 ELSE 1 END, + sort_order ASC, + created_at ASC +LIMIT 1 +` + +func (q *Queries) GetDebtBundleCategoryID(ctx context.Context, userID pgtype.UUID) (pgtype.UUID, error) { + row := q.db.QueryRow(ctx, getDebtBundleCategoryID, userID) + var id pgtype.UUID + err := row.Scan(&id) + return id, err +} + const getDebtByID = `-- name: GetDebtByID :one -SELECT id, user_id, counterparty, encrypted_counterparty, direction, amount_minor_original, amount_minor_remaining, currency, note, encrypted_note, status, source, created_at, updated_at FROM debts +SELECT id, user_id, counterparty, encrypted_counterparty, direction, amount_minor_original, amount_minor_remaining, currency, note, encrypted_note, status, source, created_at, updated_at, overpaid_amount_minor, sync_bundle_id FROM debts WHERE id = $1 AND user_id = $2 ` @@ -3165,10 +3321,146 @@ func (q *Queries) GetDebtByID(ctx context.Context, arg GetDebtByIDParams) (Debt, &i.Source, &i.CreatedAt, &i.UpdatedAt, + &i.OverpaidAmountMinor, + &i.SyncBundleID, + ) + return i, err +} + +const getDebtEventByID = `-- name: GetDebtEventByID :one +SELECT id, user_id, debt_id, kind, direction, amount_minor, currency, impact_amount_minor, impact_currency, exchange_rate, exchange_rate_date, include_in_analytics, merchant, encrypted_merchant, note, encrypted_note, source, idempotency_key, created_at, updated_at, deleted_at FROM debt_events +WHERE user_id = $1 AND id = $2 AND deleted_at IS NULL +` + +type GetDebtEventByIDParams struct { + UserID pgtype.UUID `json:"user_id"` + ID pgtype.UUID `json:"id"` +} + +func (q *Queries) GetDebtEventByID(ctx context.Context, arg GetDebtEventByIDParams) (DebtEvent, error) { + row := q.db.QueryRow(ctx, getDebtEventByID, arg.UserID, arg.ID) + var i DebtEvent + err := row.Scan( + &i.ID, + &i.UserID, + &i.DebtID, + &i.Kind, + &i.Direction, + &i.AmountMinor, + &i.Currency, + &i.ImpactAmountMinor, + &i.ImpactCurrency, + &i.ExchangeRate, + &i.ExchangeRateDate, + &i.IncludeInAnalytics, + &i.Merchant, + &i.EncryptedMerchant, + &i.Note, + &i.EncryptedNote, + &i.Source, + &i.IdempotencyKey, + &i.CreatedAt, + &i.UpdatedAt, + &i.DeletedAt, ) return i, err } +const getDebtEventSplitsByEventID = `-- name: GetDebtEventSplitsByEventID :many +SELECT id, user_id, debt_event_id, transaction_id, balance_id, amount_minor, currency, created_at, updated_at, deleted_at FROM debt_event_splits +WHERE user_id = $1 AND debt_event_id = $2 AND deleted_at IS NULL +ORDER BY created_at ASC +` + +type GetDebtEventSplitsByEventIDParams struct { + UserID pgtype.UUID `json:"user_id"` + DebtEventID pgtype.UUID `json:"debt_event_id"` +} + +func (q *Queries) GetDebtEventSplitsByEventID(ctx context.Context, arg GetDebtEventSplitsByEventIDParams) ([]DebtEventSplit, error) { + rows, err := q.db.Query(ctx, getDebtEventSplitsByEventID, arg.UserID, arg.DebtEventID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []DebtEventSplit + for rows.Next() { + var i DebtEventSplit + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.DebtEventID, + &i.TransactionID, + &i.BalanceID, + &i.AmountMinor, + &i.Currency, + &i.CreatedAt, + &i.UpdatedAt, + &i.DeletedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getDebtEventsByDebtID = `-- name: GetDebtEventsByDebtID :many +SELECT id, user_id, debt_id, kind, direction, amount_minor, currency, impact_amount_minor, impact_currency, exchange_rate, exchange_rate_date, include_in_analytics, merchant, encrypted_merchant, note, encrypted_note, source, idempotency_key, created_at, updated_at, deleted_at FROM debt_events +WHERE user_id = $1 AND debt_id = $2 AND deleted_at IS NULL +ORDER BY created_at ASC +` + +type GetDebtEventsByDebtIDParams struct { + UserID pgtype.UUID `json:"user_id"` + DebtID pgtype.UUID `json:"debt_id"` +} + +func (q *Queries) GetDebtEventsByDebtID(ctx context.Context, arg GetDebtEventsByDebtIDParams) ([]DebtEvent, error) { + rows, err := q.db.Query(ctx, getDebtEventsByDebtID, arg.UserID, arg.DebtID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []DebtEvent + for rows.Next() { + var i DebtEvent + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.DebtID, + &i.Kind, + &i.Direction, + &i.AmountMinor, + &i.Currency, + &i.ImpactAmountMinor, + &i.ImpactCurrency, + &i.ExchangeRate, + &i.ExchangeRateDate, + &i.IncludeInAnalytics, + &i.Merchant, + &i.EncryptedMerchant, + &i.Note, + &i.EncryptedNote, + &i.Source, + &i.IdempotencyKey, + &i.CreatedAt, + &i.UpdatedAt, + &i.DeletedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getDebtSummaryByUserID = `-- name: GetDebtSummaryByUserID :one SELECT COALESCE(SUM(CASE WHEN direction = 'lent' THEN amount_minor_remaining ELSE 0 END), 0)::BIGINT AS total_lent_minor, @@ -3192,7 +3484,7 @@ func (q *Queries) GetDebtSummaryByUserID(ctx context.Context, userID pgtype.UUID } const getDebtsByUserID = `-- name: GetDebtsByUserID :many -SELECT id, user_id, counterparty, encrypted_counterparty, direction, amount_minor_original, amount_minor_remaining, currency, note, encrypted_note, status, source, created_at, updated_at FROM debts +SELECT id, user_id, counterparty, encrypted_counterparty, direction, amount_minor_original, amount_minor_remaining, currency, note, encrypted_note, status, source, created_at, updated_at, overpaid_amount_minor, sync_bundle_id FROM debts WHERE user_id = $1 AND ($2::bool = true OR status = 'open') ORDER BY created_at DESC @@ -3227,6 +3519,8 @@ func (q *Queries) GetDebtsByUserID(ctx context.Context, arg GetDebtsByUserIDPara &i.Source, &i.CreatedAt, &i.UpdatedAt, + &i.OverpaidAmountMinor, + &i.SyncBundleID, ); err != nil { return nil, err } @@ -3752,7 +4046,7 @@ func (q *Queries) GetRefreshTokenByID(ctx context.Context, id pgtype.UUID) (Refr } const getStoreProductByID = `-- name: GetStoreProductByID :one -SELECT id, plan_id, provider, store_product_id, period, price_minor, currency_code, is_active, created_at, updated_at FROM store_products WHERE id = $1 +SELECT id, plan_id, provider, store_product_id, period, price_minor, currency_code, trial_days, trial_interval, trial_interval_count, is_active, created_at, updated_at FROM store_products WHERE id = $1 ` func (q *Queries) GetStoreProductByID(ctx context.Context, id pgtype.UUID) (StoreProduct, error) { @@ -3766,6 +4060,9 @@ func (q *Queries) GetStoreProductByID(ctx context.Context, id pgtype.UUID) (Stor &i.Period, &i.PriceMinor, &i.CurrencyCode, + &i.TrialDays, + &i.TrialInterval, + &i.TrialIntervalCount, &i.IsActive, &i.CreatedAt, &i.UpdatedAt, @@ -3774,7 +4071,7 @@ func (q *Queries) GetStoreProductByID(ctx context.Context, id pgtype.UUID) (Stor } const getStoreProductByProviderAndProductID = `-- name: GetStoreProductByProviderAndProductID :one -SELECT id, plan_id, provider, store_product_id, period, price_minor, currency_code, is_active, created_at, updated_at FROM store_products WHERE provider = $1 AND store_product_id = $2 AND is_active = true +SELECT id, plan_id, provider, store_product_id, period, price_minor, currency_code, trial_days, trial_interval, trial_interval_count, is_active, created_at, updated_at FROM store_products WHERE provider = $1 AND store_product_id = $2 AND is_active = true ` type GetStoreProductByProviderAndProductIDParams struct { @@ -3793,6 +4090,9 @@ func (q *Queries) GetStoreProductByProviderAndProductID(ctx context.Context, arg &i.Period, &i.PriceMinor, &i.CurrencyCode, + &i.TrialDays, + &i.TrialInterval, + &i.TrialIntervalCount, &i.IsActive, &i.CreatedAt, &i.UpdatedAt, @@ -4422,7 +4722,7 @@ func (q *Queries) GetTransactionsByUserIDAndType(ctx context.Context, arg GetTra } const getTransactionsUpdatedSince = `-- name: GetTransactionsUpdatedSince :many -SELECT id, user_id, category_id, balance_id, batch_id, type, currency, merchant, note, raw_query, occurred_at, source, version, created_at, updated_at, deleted_at, encrypted_amount, encrypted_merchant, encrypted_note, encrypted_raw_query, orphaned_at, debt_id FROM transactions +SELECT id, user_id, category_id, balance_id, batch_id, type, currency, merchant, note, raw_query, occurred_at, source, version, created_at, updated_at, deleted_at, debt_event_id, include_in_analytics, encrypted_amount, encrypted_merchant, encrypted_note, encrypted_raw_query, orphaned_at, debt_id FROM transactions WHERE user_id = $1 AND updated_at > $2 AND orphaned_at IS NULL AND deleted_at IS NULL ` @@ -4457,6 +4757,8 @@ func (q *Queries) GetTransactionsUpdatedSince(ctx context.Context, arg GetTransa &i.CreatedAt, &i.UpdatedAt, &i.DeletedAt, + &i.DebtEventID, + &i.IncludeInAnalytics, &i.EncryptedAmount, &i.EncryptedMerchant, &i.EncryptedNote, @@ -4475,7 +4777,7 @@ func (q *Queries) GetTransactionsUpdatedSince(ctx context.Context, arg GetTransa } const getUserAdminDetail = `-- name: GetUserAdminDetail :one -SELECT u.id, u.picture, u.name, u.email, u.sub, u.role, u.currency, u.ui_language, u.timezone, u.onboarding_completed, u.context, u.context_summary, u.last_activity, u.created_at, u.updated_at, u.deleted_at, u.public_key, u.encrypted_dek_backup, u.encrypted_privkey_backup, u.key_created_at, u.has_encryption_keys, u.encrypted_context, u.encrypted_context_summary, u.deletion_requested_at, u.deletion_scheduled_at, u.last_downgraded_at, u.last_downgrade_reason, u.downgrade_effects, +SELECT u.id, u.picture, u.name, u.email, u.sub, u.role, u.currency, u.ui_language, u.timezone, u.onboarding_completed, u.context, u.context_summary, u.last_activity, u.created_at, u.updated_at, u.deleted_at, u.financial_goal, u.main_challenge, u.experience_level, u.public_key, u.encrypted_dek_backup, u.encrypted_privkey_backup, u.key_created_at, u.has_encryption_keys, u.encrypted_context, u.encrypted_context_summary, u.deletion_requested_at, u.deletion_scheduled_at, u.last_downgraded_at, u.last_downgrade_reason, u.downgrade_effects, e.plan_id AS entitlement_plan, e.billing_period AS entitlement_period, e.active_until AS entitlement_until @@ -4501,6 +4803,9 @@ type GetUserAdminDetailRow struct { CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` DeletedAt pgtype.Timestamptz `json:"deleted_at"` + FinancialGoal *string `json:"financial_goal"` + MainChallenge *string `json:"main_challenge"` + ExperienceLevel *string `json:"experience_level"` PublicKey *string `json:"public_key"` EncryptedDekBackup *string `json:"encrypted_dek_backup"` EncryptedPrivkeyBackup *string `json:"encrypted_privkey_backup"` @@ -4538,6 +4843,9 @@ func (q *Queries) GetUserAdminDetail(ctx context.Context, id pgtype.UUID) (GetUs &i.CreatedAt, &i.UpdatedAt, &i.DeletedAt, + &i.FinancialGoal, + &i.MainChallenge, + &i.ExperienceLevel, &i.PublicKey, &i.EncryptedDekBackup, &i.EncryptedPrivkeyBackup, @@ -4558,7 +4866,7 @@ func (q *Queries) GetUserAdminDetail(ctx context.Context, id pgtype.UUID) (GetUs } const getUserByID = `-- name: GetUserByID :one -SELECT id, picture, name, email, sub, role, currency, ui_language, timezone, onboarding_completed, context, context_summary, last_activity, created_at, updated_at, deleted_at, public_key, encrypted_dek_backup, encrypted_privkey_backup, key_created_at, has_encryption_keys, encrypted_context, encrypted_context_summary, deletion_requested_at, deletion_scheduled_at, last_downgraded_at, last_downgrade_reason, downgrade_effects FROM users WHERE id = $1 AND deleted_at IS NULL +SELECT id, picture, name, email, sub, role, currency, ui_language, timezone, onboarding_completed, context, context_summary, last_activity, created_at, updated_at, deleted_at, financial_goal, main_challenge, experience_level, public_key, encrypted_dek_backup, encrypted_privkey_backup, key_created_at, has_encryption_keys, encrypted_context, encrypted_context_summary, deletion_requested_at, deletion_scheduled_at, last_downgraded_at, last_downgrade_reason, downgrade_effects FROM users WHERE id = $1 AND deleted_at IS NULL ` func (q *Queries) GetUserByID(ctx context.Context, id pgtype.UUID) (User, error) { @@ -4581,6 +4889,9 @@ func (q *Queries) GetUserByID(ctx context.Context, id pgtype.UUID) (User, error) &i.CreatedAt, &i.UpdatedAt, &i.DeletedAt, + &i.FinancialGoal, + &i.MainChallenge, + &i.ExperienceLevel, &i.PublicKey, &i.EncryptedDekBackup, &i.EncryptedPrivkeyBackup, @@ -4598,7 +4909,7 @@ func (q *Queries) GetUserByID(ctx context.Context, id pgtype.UUID) (User, error) } const getUserBySub = `-- name: GetUserBySub :one -SELECT id, picture, name, email, sub, role, currency, ui_language, timezone, onboarding_completed, context, context_summary, last_activity, created_at, updated_at, deleted_at, public_key, encrypted_dek_backup, encrypted_privkey_backup, key_created_at, has_encryption_keys, encrypted_context, encrypted_context_summary, deletion_requested_at, deletion_scheduled_at, last_downgraded_at, last_downgrade_reason, downgrade_effects +SELECT id, picture, name, email, sub, role, currency, ui_language, timezone, onboarding_completed, context, context_summary, last_activity, created_at, updated_at, deleted_at, financial_goal, main_challenge, experience_level, public_key, encrypted_dek_backup, encrypted_privkey_backup, key_created_at, has_encryption_keys, encrypted_context, encrypted_context_summary, deletion_requested_at, deletion_scheduled_at, last_downgraded_at, last_downgrade_reason, downgrade_effects FROM users WHERE sub = $1 AND deleted_at IS NULL ` @@ -4623,6 +4934,9 @@ func (q *Queries) GetUserBySub(ctx context.Context, sub *string) (User, error) { &i.CreatedAt, &i.UpdatedAt, &i.DeletedAt, + &i.FinancialGoal, + &i.MainChallenge, + &i.ExperienceLevel, &i.PublicKey, &i.EncryptedDekBackup, &i.EncryptedPrivkeyBackup, @@ -4760,7 +5074,7 @@ func (q *Queries) GetUsersPendingDeletion(ctx context.Context) ([]pgtype.UUID, e } const hardDeleteUser = `-- name: HardDeleteUser :exec -UPDATE users SET deleted_at = NOW() WHERE id = $1 +DELETE FROM users WHERE id = $1 ` func (q *Queries) HardDeleteUser(ctx context.Context, id pgtype.UUID) error { @@ -4835,66 +5149,30 @@ func (q *Queries) ListBannedEmails(ctx context.Context) ([]BannedEmail, error) { return items, nil } -const listBillingJobsAdmin = `-- name: ListBillingJobsAdmin :many -SELECT bj.id, bj.subscription_id, bj.user_id, bj.amount_minor, bj.currency_code, - bj.status, bj.run_at, bj.claimed_at, bj.completed_at, bj.error_message, - bj.retry_count, bj.max_retries, bj.created_at, - u.email AS user_email -FROM billing_jobs bj -JOIN users u ON u.id = bj.user_id -WHERE ($1::text = '' OR bj.status = $1) -ORDER BY bj.run_at DESC -LIMIT $2 OFFSET $3 +const listEligibleGeminiKeys = `-- name: ListEligibleGeminiKeys :many +SELECT id, api_key FROM ai_credentials +WHERE is_active = true + AND provider = 'gemini' + AND (token_limit - total_tokens) > 2048 + AND requests_today < 20 +ORDER BY total_tokens ASC, id ASC ` -type ListBillingJobsAdminParams struct { - Column1 string `json:"column_1"` - Limit int32 `json:"limit"` - Offset int32 `json:"offset"` -} - -type ListBillingJobsAdminRow struct { - ID pgtype.UUID `json:"id"` - SubscriptionID pgtype.UUID `json:"subscription_id"` - UserID pgtype.UUID `json:"user_id"` - AmountMinor int64 `json:"amount_minor"` - CurrencyCode string `json:"currency_code"` - Status string `json:"status"` - RunAt pgtype.Timestamptz `json:"run_at"` - ClaimedAt pgtype.Timestamptz `json:"claimed_at"` - CompletedAt pgtype.Timestamptz `json:"completed_at"` - ErrorMessage *string `json:"error_message"` - RetryCount int32 `json:"retry_count"` - MaxRetries int32 `json:"max_retries"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - UserEmail string `json:"user_email"` +type ListEligibleGeminiKeysRow struct { + ID pgtype.UUID `json:"id"` + ApiKey string `json:"api_key"` } -func (q *Queries) ListBillingJobsAdmin(ctx context.Context, arg ListBillingJobsAdminParams) ([]ListBillingJobsAdminRow, error) { - rows, err := q.db.Query(ctx, listBillingJobsAdmin, arg.Column1, arg.Limit, arg.Offset) +func (q *Queries) ListEligibleGeminiKeys(ctx context.Context) ([]ListEligibleGeminiKeysRow, error) { + rows, err := q.db.Query(ctx, listEligibleGeminiKeys) if err != nil { return nil, err } defer rows.Close() - var items []ListBillingJobsAdminRow + var items []ListEligibleGeminiKeysRow for rows.Next() { - var i ListBillingJobsAdminRow - if err := rows.Scan( - &i.ID, - &i.SubscriptionID, - &i.UserID, - &i.AmountMinor, - &i.CurrencyCode, - &i.Status, - &i.RunAt, - &i.ClaimedAt, - &i.CompletedAt, - &i.ErrorMessage, - &i.RetryCount, - &i.MaxRetries, - &i.CreatedAt, - &i.UserEmail, - ); err != nil { + var i ListEligibleGeminiKeysRow + if err := rows.Scan(&i.ID, &i.ApiKey); err != nil { return nil, err } items = append(items, i) @@ -5142,7 +5420,7 @@ func (q *Queries) ListUserSessions(ctx context.Context, userID pgtype.UUID) ([]L } const listUsersAdmin = `-- name: ListUsersAdmin :many -SELECT u.id, u.picture, u.name, u.email, u.sub, u.role, u.currency, u.ui_language, u.timezone, u.onboarding_completed, u.context, u.context_summary, u.last_activity, u.created_at, u.updated_at, u.deleted_at, u.public_key, u.encrypted_dek_backup, u.encrypted_privkey_backup, u.key_created_at, u.has_encryption_keys, u.encrypted_context, u.encrypted_context_summary, u.deletion_requested_at, u.deletion_scheduled_at, u.last_downgraded_at, u.last_downgrade_reason, u.downgrade_effects, +SELECT u.id, u.picture, u.name, u.email, u.sub, u.role, u.currency, u.ui_language, u.timezone, u.onboarding_completed, u.context, u.context_summary, u.last_activity, u.created_at, u.updated_at, u.deleted_at, u.financial_goal, u.main_challenge, u.experience_level, u.public_key, u.encrypted_dek_backup, u.encrypted_privkey_backup, u.key_created_at, u.has_encryption_keys, u.encrypted_context, u.encrypted_context_summary, u.deletion_requested_at, u.deletion_scheduled_at, u.last_downgraded_at, u.last_downgrade_reason, u.downgrade_effects, e.plan_id AS entitlement_plan, e.active_until AS entitlement_until FROM users u @@ -5176,6 +5454,9 @@ type ListUsersAdminRow struct { CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` DeletedAt pgtype.Timestamptz `json:"deleted_at"` + FinancialGoal *string `json:"financial_goal"` + MainChallenge *string `json:"main_challenge"` + ExperienceLevel *string `json:"experience_level"` PublicKey *string `json:"public_key"` EncryptedDekBackup *string `json:"encrypted_dek_backup"` EncryptedPrivkeyBackup *string `json:"encrypted_privkey_backup"` @@ -5218,6 +5499,9 @@ func (q *Queries) ListUsersAdmin(ctx context.Context, arg ListUsersAdminParams) &i.CreatedAt, &i.UpdatedAt, &i.DeletedAt, + &i.FinancialGoal, + &i.MainChallenge, + &i.ExperienceLevel, &i.PublicKey, &i.EncryptedDekBackup, &i.EncryptedPrivkeyBackup, @@ -5293,6 +5577,83 @@ func (q *Queries) RecordUserDowngrade(ctx context.Context, arg RecordUserDowngra return err } +const relinkDebtBundleTransaction = `-- name: RelinkDebtBundleTransaction :one +UPDATE transactions +SET debt_id = $3, + debt_event_id = $4, + include_in_analytics = $5, + encrypted_amount = $6, + merchant = $7, + encrypted_merchant = $8, + note = $9, + encrypted_note = $10, + raw_query = $11, + encrypted_raw_query = $12, + updated_at = now() +WHERE user_id = $1 AND id = $2 AND deleted_at IS NULL +RETURNING id, user_id, category_id, balance_id, batch_id, type, currency, merchant, note, raw_query, occurred_at, source, version, created_at, updated_at, deleted_at, debt_event_id, include_in_analytics, encrypted_amount, encrypted_merchant, encrypted_note, encrypted_raw_query, orphaned_at, debt_id +` + +type RelinkDebtBundleTransactionParams struct { + UserID pgtype.UUID `json:"user_id"` + ID pgtype.UUID `json:"id"` + DebtID pgtype.UUID `json:"debt_id"` + DebtEventID pgtype.UUID `json:"debt_event_id"` + IncludeInAnalytics bool `json:"include_in_analytics"` + EncryptedAmount *string `json:"encrypted_amount"` + Merchant *string `json:"merchant"` + EncryptedMerchant *string `json:"encrypted_merchant"` + Note *string `json:"note"` + EncryptedNote *string `json:"encrypted_note"` + RawQuery *string `json:"raw_query"` + EncryptedRawQuery *string `json:"encrypted_raw_query"` +} + +func (q *Queries) RelinkDebtBundleTransaction(ctx context.Context, arg RelinkDebtBundleTransactionParams) (Transaction, error) { + row := q.db.QueryRow(ctx, relinkDebtBundleTransaction, + arg.UserID, + arg.ID, + arg.DebtID, + arg.DebtEventID, + arg.IncludeInAnalytics, + arg.EncryptedAmount, + arg.Merchant, + arg.EncryptedMerchant, + arg.Note, + arg.EncryptedNote, + arg.RawQuery, + arg.EncryptedRawQuery, + ) + var i Transaction + err := row.Scan( + &i.ID, + &i.UserID, + &i.CategoryID, + &i.BalanceID, + &i.BatchID, + &i.Type, + &i.Currency, + &i.Merchant, + &i.Note, + &i.RawQuery, + &i.OccurredAt, + &i.Source, + &i.Version, + &i.CreatedAt, + &i.UpdatedAt, + &i.DeletedAt, + &i.DebtEventID, + &i.IncludeInAnalytics, + &i.EncryptedAmount, + &i.EncryptedMerchant, + &i.EncryptedNote, + &i.EncryptedRawQuery, + &i.OrphanedAt, + &i.DebtID, + ) + return i, err +} + const reorderCategories = `-- name: ReorderCategories :exec UPDATE categories SET sort_order = new_order.sort_order, @@ -5342,34 +5703,6 @@ func (q *Queries) ResetDailyRequestCounts(ctx context.Context) error { return err } -const retryBillingJob = `-- name: RetryBillingJob :one -UPDATE billing_jobs -SET status = 'pending', run_at = NOW(), error_message = NULL -WHERE id = $1 AND status IN ('failed', 'exhausted') -RETURNING id, subscription_id, user_id, amount_minor, currency_code, status, run_at, claimed_at, completed_at, error_message, retry_count, max_retries, created_at -` - -func (q *Queries) RetryBillingJob(ctx context.Context, id pgtype.UUID) (BillingJob, error) { - row := q.db.QueryRow(ctx, retryBillingJob, id) - var i BillingJob - err := row.Scan( - &i.ID, - &i.SubscriptionID, - &i.UserID, - &i.AmountMinor, - &i.CurrencyCode, - &i.Status, - &i.RunAt, - &i.ClaimedAt, - &i.CompletedAt, - &i.ErrorMessage, - &i.RetryCount, - &i.MaxRetries, - &i.CreatedAt, - ) - return i, err -} - const rotateRefreshTokens = `-- name: RotateRefreshTokens :exec DELETE FROM refresh_tokens WHERE id IN ( @@ -5469,6 +5802,38 @@ func (q *Queries) SoftDeleteDebt(ctx context.Context, arg SoftDeleteDebtParams) return err } +const softDeleteDebtEvent = `-- name: SoftDeleteDebtEvent :exec +UPDATE debt_events +SET deleted_at = now(), updated_at = now() +WHERE user_id = $1 AND id = $2 AND deleted_at IS NULL +` + +type SoftDeleteDebtEventParams struct { + UserID pgtype.UUID `json:"user_id"` + ID pgtype.UUID `json:"id"` +} + +func (q *Queries) SoftDeleteDebtEvent(ctx context.Context, arg SoftDeleteDebtEventParams) error { + _, err := q.db.Exec(ctx, softDeleteDebtEvent, arg.UserID, arg.ID) + return err +} + +const softDeleteDebtEventSplitsByEventID = `-- name: SoftDeleteDebtEventSplitsByEventID :exec +UPDATE debt_event_splits +SET deleted_at = now(), updated_at = now() +WHERE user_id = $1 AND debt_event_id = $2 AND deleted_at IS NULL +` + +type SoftDeleteDebtEventSplitsByEventIDParams struct { + UserID pgtype.UUID `json:"user_id"` + DebtEventID pgtype.UUID `json:"debt_event_id"` +} + +func (q *Queries) SoftDeleteDebtEventSplitsByEventID(ctx context.Context, arg SoftDeleteDebtEventSplitsByEventIDParams) error { + _, err := q.db.Exec(ctx, softDeleteDebtEventSplitsByEventID, arg.UserID, arg.DebtEventID) + return err +} + const softDeleteTransactionFromSync = `-- name: SoftDeleteTransactionFromSync :exec UPDATE transactions SET deleted_at = NOW(), updated_at = NOW() WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL @@ -5484,6 +5849,22 @@ func (q *Queries) SoftDeleteTransactionFromSync(ctx context.Context, arg SoftDel return err } +const softDeleteTransactionsByDebtEventID = `-- name: SoftDeleteTransactionsByDebtEventID :exec +UPDATE transactions +SET deleted_at = now(), updated_at = now() +WHERE user_id = $1 AND debt_event_id = $2 AND deleted_at IS NULL +` + +type SoftDeleteTransactionsByDebtEventIDParams struct { + UserID pgtype.UUID `json:"user_id"` + DebtEventID pgtype.UUID `json:"debt_event_id"` +} + +func (q *Queries) SoftDeleteTransactionsByDebtEventID(ctx context.Context, arg SoftDeleteTransactionsByDebtEventIDParams) error { + _, err := q.db.Exec(ctx, softDeleteTransactionsByDebtEventID, arg.UserID, arg.DebtEventID) + return err +} + const storeUserKeys = `-- name: StoreUserKeys :exec UPDATE users SET @@ -5698,6 +6079,36 @@ func (q *Queries) UpdateBalance(ctx context.Context, arg UpdateBalanceParams) (B return i, err } +const updateBalanceSnapshotFromSync = `-- name: UpdateBalanceSnapshotFromSync :exec +UPDATE balances +SET encrypted_balance_snapshot = $3, + snapshot_tx_count = $4, + snapshot_updated_at = $5, + updated_at = CURRENT_TIMESTAMP +WHERE id = $1 + AND user_id = $2 + AND deleted_at IS NULL +` + +type UpdateBalanceSnapshotFromSyncParams struct { + ID pgtype.UUID `json:"id"` + UserID pgtype.UUID `json:"user_id"` + EncryptedBalanceSnapshot *string `json:"encrypted_balance_snapshot"` + SnapshotTxCount *int32 `json:"snapshot_tx_count"` + SnapshotUpdatedAt pgtype.Timestamptz `json:"snapshot_updated_at"` +} + +func (q *Queries) UpdateBalanceSnapshotFromSync(ctx context.Context, arg UpdateBalanceSnapshotFromSyncParams) error { + _, err := q.db.Exec(ctx, updateBalanceSnapshotFromSync, + arg.ID, + arg.UserID, + arg.EncryptedBalanceSnapshot, + arg.SnapshotTxCount, + arg.SnapshotUpdatedAt, + ) + return err +} + const updateCategory = `-- name: UpdateCategory :one UPDATE categories SET title = COALESCE(NULLIF($1::text, ''), title), @@ -5804,7 +6215,7 @@ SET counterparty = COALESCE(NULLIF($3, ''), counterparty), encrypted_note = $6, updated_at = NOW() WHERE id = $1 AND user_id = $2 -RETURNING id, user_id, counterparty, encrypted_counterparty, direction, amount_minor_original, amount_minor_remaining, currency, note, encrypted_note, status, source, created_at, updated_at +RETURNING id, user_id, counterparty, encrypted_counterparty, direction, amount_minor_original, amount_minor_remaining, currency, note, encrypted_note, status, source, created_at, updated_at, overpaid_amount_minor, sync_bundle_id ` type UpdateDebtParams struct { @@ -5841,6 +6252,136 @@ func (q *Queries) UpdateDebt(ctx context.Context, arg UpdateDebtParams) (Debt, e &i.Source, &i.CreatedAt, &i.UpdatedAt, + &i.OverpaidAmountMinor, + &i.SyncBundleID, + ) + return i, err +} + +const updateDebtEncryptedFields = `-- name: UpdateDebtEncryptedFields :one +UPDATE debts +SET counterparty = $3, + encrypted_counterparty = $4, + note = $5, + encrypted_note = $6, + updated_at = now() +WHERE user_id = $1 AND id = $2 AND deleted_at IS NULL +RETURNING id, user_id, counterparty, encrypted_counterparty, direction, amount_minor_original, amount_minor_remaining, currency, note, encrypted_note, status, source, created_at, updated_at, overpaid_amount_minor, sync_bundle_id +` + +type UpdateDebtEncryptedFieldsParams struct { + UserID pgtype.UUID `json:"user_id"` + ID pgtype.UUID `json:"id"` + Counterparty string `json:"counterparty"` + EncryptedCounterparty *string `json:"encrypted_counterparty"` + Note *string `json:"note"` + EncryptedNote *string `json:"encrypted_note"` +} + +func (q *Queries) UpdateDebtEncryptedFields(ctx context.Context, arg UpdateDebtEncryptedFieldsParams) (Debt, error) { + row := q.db.QueryRow(ctx, updateDebtEncryptedFields, + arg.UserID, + arg.ID, + arg.Counterparty, + arg.EncryptedCounterparty, + arg.Note, + arg.EncryptedNote, + ) + var i Debt + err := row.Scan( + &i.ID, + &i.UserID, + &i.Counterparty, + &i.EncryptedCounterparty, + &i.Direction, + &i.AmountMinorOriginal, + &i.AmountMinorRemaining, + &i.Currency, + &i.Note, + &i.EncryptedNote, + &i.Status, + &i.Source, + &i.CreatedAt, + &i.UpdatedAt, + &i.OverpaidAmountMinor, + &i.SyncBundleID, + ) + return i, err +} + +const updateDebtEvent = `-- name: UpdateDebtEvent :one +UPDATE debt_events +SET amount_minor = $3, + currency = $4, + impact_amount_minor = $5, + impact_currency = $6, + exchange_rate = $7, + exchange_rate_date = $8, + include_in_analytics = $9, + merchant = $10, + encrypted_merchant = $11, + note = $12, + encrypted_note = $13, + updated_at = now() +WHERE user_id = $1 AND id = $2 AND deleted_at IS NULL +RETURNING id, user_id, debt_id, kind, direction, amount_minor, currency, impact_amount_minor, impact_currency, exchange_rate, exchange_rate_date, include_in_analytics, merchant, encrypted_merchant, note, encrypted_note, source, idempotency_key, created_at, updated_at, deleted_at +` + +type UpdateDebtEventParams struct { + UserID pgtype.UUID `json:"user_id"` + ID pgtype.UUID `json:"id"` + AmountMinor int64 `json:"amount_minor"` + Currency string `json:"currency"` + ImpactAmountMinor int64 `json:"impact_amount_minor"` + ImpactCurrency string `json:"impact_currency"` + ExchangeRate pgtype.Numeric `json:"exchange_rate"` + ExchangeRateDate pgtype.Timestamptz `json:"exchange_rate_date"` + IncludeInAnalytics bool `json:"include_in_analytics"` + Merchant *string `json:"merchant"` + EncryptedMerchant *string `json:"encrypted_merchant"` + Note *string `json:"note"` + EncryptedNote *string `json:"encrypted_note"` +} + +func (q *Queries) UpdateDebtEvent(ctx context.Context, arg UpdateDebtEventParams) (DebtEvent, error) { + row := q.db.QueryRow(ctx, updateDebtEvent, + arg.UserID, + arg.ID, + arg.AmountMinor, + arg.Currency, + arg.ImpactAmountMinor, + arg.ImpactCurrency, + arg.ExchangeRate, + arg.ExchangeRateDate, + arg.IncludeInAnalytics, + arg.Merchant, + arg.EncryptedMerchant, + arg.Note, + arg.EncryptedNote, + ) + var i DebtEvent + err := row.Scan( + &i.ID, + &i.UserID, + &i.DebtID, + &i.Kind, + &i.Direction, + &i.AmountMinor, + &i.Currency, + &i.ImpactAmountMinor, + &i.ImpactCurrency, + &i.ExchangeRate, + &i.ExchangeRateDate, + &i.IncludeInAnalytics, + &i.Merchant, + &i.EncryptedMerchant, + &i.Note, + &i.EncryptedNote, + &i.Source, + &i.IdempotencyKey, + &i.CreatedAt, + &i.UpdatedAt, + &i.DeletedAt, ) return i, err } @@ -5851,7 +6392,7 @@ SET amount_minor_remaining = $3, status = CASE WHEN $3 <= 0 THEN 'archived' ELSE status END, updated_at = NOW() WHERE id = $1 AND user_id = $2 -RETURNING id, user_id, counterparty, encrypted_counterparty, direction, amount_minor_original, amount_minor_remaining, currency, note, encrypted_note, status, source, created_at, updated_at +RETURNING id, user_id, counterparty, encrypted_counterparty, direction, amount_minor_original, amount_minor_remaining, currency, note, encrypted_note, status, source, created_at, updated_at, overpaid_amount_minor, sync_bundle_id ` type UpdateDebtRemainingParams struct { @@ -5878,10 +6419,40 @@ func (q *Queries) UpdateDebtRemaining(ctx context.Context, arg UpdateDebtRemaini &i.Source, &i.CreatedAt, &i.UpdatedAt, + &i.OverpaidAmountMinor, + &i.SyncBundleID, ) return i, err } +const updateDebtRemainingAndStatus = `-- name: UpdateDebtRemainingAndStatus :exec +UPDATE debts +SET amount_minor_remaining = $3, + overpaid_amount_minor = $4, + status = $5, + updated_at = now() +WHERE user_id = $1 AND id = $2 +` + +type UpdateDebtRemainingAndStatusParams struct { + UserID pgtype.UUID `json:"user_id"` + ID pgtype.UUID `json:"id"` + AmountMinorRemaining int64 `json:"amount_minor_remaining"` + OverpaidAmountMinor int64 `json:"overpaid_amount_minor"` + Status string `json:"status"` +} + +func (q *Queries) UpdateDebtRemainingAndStatus(ctx context.Context, arg UpdateDebtRemainingAndStatusParams) error { + _, err := q.db.Exec(ctx, updateDebtRemainingAndStatus, + arg.UserID, + arg.ID, + arg.AmountMinorRemaining, + arg.OverpaidAmountMinor, + arg.Status, + ) + return err +} + const updateSubscriptionBillingState = `-- name: UpdateSubscriptionBillingState :exec UPDATE subscriptions SET billing_anchor_day = $2, @@ -6046,7 +6617,7 @@ UPDATE transactions SET version = version + 1, updated_at = CURRENT_TIMESTAMP WHERE id = $13 AND user_id = $14 AND version = $15 AND deleted_at IS NULL -RETURNING id, user_id, category_id, balance_id, batch_id, type, currency, merchant, note, raw_query, occurred_at, source, version, created_at, updated_at, deleted_at, encrypted_amount, encrypted_merchant, encrypted_note, encrypted_raw_query, orphaned_at, debt_id +RETURNING id, user_id, category_id, balance_id, batch_id, type, currency, merchant, note, raw_query, occurred_at, source, version, created_at, updated_at, deleted_at, debt_event_id, include_in_analytics, encrypted_amount, encrypted_merchant, encrypted_note, encrypted_raw_query, orphaned_at, debt_id ` type UpdateTransactionParams struct { @@ -6103,6 +6674,8 @@ func (q *Queries) UpdateTransaction(ctx context.Context, arg UpdateTransactionPa &i.CreatedAt, &i.UpdatedAt, &i.DeletedAt, + &i.DebtEventID, + &i.IncludeInAnalytics, &i.EncryptedAmount, &i.EncryptedMerchant, &i.EncryptedNote, @@ -6120,7 +6693,7 @@ UPDATE users SET context_summary = $3, updated_at = CURRENT_TIMESTAMP WHERE id = $1 AND deleted_at IS NULL -RETURNING id, picture, name, email, sub, role, currency, ui_language, timezone, onboarding_completed, context, context_summary, last_activity, created_at, updated_at, deleted_at, public_key, encrypted_dek_backup, encrypted_privkey_backup, key_created_at, has_encryption_keys, encrypted_context, encrypted_context_summary, deletion_requested_at, deletion_scheduled_at, last_downgraded_at, last_downgrade_reason, downgrade_effects +RETURNING id, picture, name, email, sub, role, currency, ui_language, timezone, onboarding_completed, context, context_summary, last_activity, created_at, updated_at, deleted_at, financial_goal, main_challenge, experience_level, public_key, encrypted_dek_backup, encrypted_privkey_backup, key_created_at, has_encryption_keys, encrypted_context, encrypted_context_summary, deletion_requested_at, deletion_scheduled_at, last_downgraded_at, last_downgrade_reason, downgrade_effects ` type UpdateUserContextParams struct { @@ -6152,6 +6725,9 @@ func (q *Queries) UpdateUserContext(ctx context.Context, arg UpdateUserContextPa &i.CreatedAt, &i.UpdatedAt, &i.DeletedAt, + &i.FinancialGoal, + &i.MainChallenge, + &i.ExperienceLevel, &i.PublicKey, &i.EncryptedDekBackup, &i.EncryptedPrivkeyBackup, @@ -6168,6 +6744,33 @@ func (q *Queries) UpdateUserContext(ctx context.Context, arg UpdateUserContextPa return i, err } +const updateUserOnboardingContext = `-- name: UpdateUserOnboardingContext :exec +UPDATE users +SET + financial_goal = COALESCE($1, financial_goal), + main_challenge = COALESCE($2, main_challenge), + experience_level = COALESCE($3, experience_level), + updated_at = CURRENT_TIMESTAMP +WHERE id = $4 AND deleted_at IS NULL +` + +type UpdateUserOnboardingContextParams struct { + FinancialGoal *string `json:"financial_goal"` + MainChallenge *string `json:"main_challenge"` + ExperienceLevel *string `json:"experience_level"` + ID pgtype.UUID `json:"id"` +} + +func (q *Queries) UpdateUserOnboardingContext(ctx context.Context, arg UpdateUserOnboardingContextParams) error { + _, err := q.db.Exec(ctx, updateUserOnboardingContext, + arg.FinancialGoal, + arg.MainChallenge, + arg.ExperienceLevel, + arg.ID, + ) + return err +} + const updateUserPreferences = `-- name: UpdateUserPreferences :one UPDATE users SET currency = COALESCE(NULLIF($1::text, ''), currency), @@ -6176,7 +6779,7 @@ SET currency = COALESCE(NULLIF($1::text, ''), currency), onboarding_completed = onboarding_completed OR $4, updated_at = CURRENT_TIMESTAMP WHERE sub = $5 AND deleted_at IS NULL -RETURNING id, picture, name, email, sub, role, currency, ui_language, timezone, onboarding_completed, context, context_summary, last_activity, created_at, updated_at, deleted_at, public_key, encrypted_dek_backup, encrypted_privkey_backup, key_created_at, has_encryption_keys, encrypted_context, encrypted_context_summary, deletion_requested_at, deletion_scheduled_at, last_downgraded_at, last_downgrade_reason, downgrade_effects +RETURNING id, picture, name, email, sub, role, currency, ui_language, timezone, onboarding_completed, context, context_summary, last_activity, created_at, updated_at, deleted_at, financial_goal, main_challenge, experience_level, public_key, encrypted_dek_backup, encrypted_privkey_backup, key_created_at, has_encryption_keys, encrypted_context, encrypted_context_summary, deletion_requested_at, deletion_scheduled_at, last_downgraded_at, last_downgrade_reason, downgrade_effects ` type UpdateUserPreferencesParams struct { @@ -6213,6 +6816,9 @@ func (q *Queries) UpdateUserPreferences(ctx context.Context, arg UpdateUserPrefe &i.CreatedAt, &i.UpdatedAt, &i.DeletedAt, + &i.FinancialGoal, + &i.MainChallenge, + &i.ExperienceLevel, &i.PublicKey, &i.EncryptedDekBackup, &i.EncryptedPrivkeyBackup, @@ -6232,7 +6838,7 @@ func (q *Queries) UpdateUserPreferences(ctx context.Context, arg UpdateUserPrefe const updateUserRole = `-- name: UpdateUserRole :one UPDATE users SET role = $2, updated_at = NOW() WHERE id = $1 -RETURNING id, picture, name, email, sub, role, currency, ui_language, timezone, onboarding_completed, context, context_summary, last_activity, created_at, updated_at, deleted_at, public_key, encrypted_dek_backup, encrypted_privkey_backup, key_created_at, has_encryption_keys, encrypted_context, encrypted_context_summary, deletion_requested_at, deletion_scheduled_at, last_downgraded_at, last_downgrade_reason, downgrade_effects +RETURNING id, picture, name, email, sub, role, currency, ui_language, timezone, onboarding_completed, context, context_summary, last_activity, created_at, updated_at, deleted_at, financial_goal, main_challenge, experience_level, public_key, encrypted_dek_backup, encrypted_privkey_backup, key_created_at, has_encryption_keys, encrypted_context, encrypted_context_summary, deletion_requested_at, deletion_scheduled_at, last_downgraded_at, last_downgrade_reason, downgrade_effects ` type UpdateUserRoleParams struct { @@ -6260,6 +6866,9 @@ func (q *Queries) UpdateUserRole(ctx context.Context, arg UpdateUserRoleParams) &i.CreatedAt, &i.UpdatedAt, &i.DeletedAt, + &i.FinancialGoal, + &i.MainChallenge, + &i.ExperienceLevel, &i.PublicKey, &i.EncryptedDekBackup, &i.EncryptedPrivkeyBackup, @@ -6494,7 +7103,7 @@ func (q *Queries) UpsertContextTemplateTranslation(ctx context.Context, arg Upse const upsertEntitlement = `-- name: UpsertEntitlement :exec INSERT INTO entitlements (user_id, plan_id, billing_period, active_until) VALUES ($1, $2, $3, $4) -ON CONFLICT (user_id) DO UPDATE SET plan_id = $2, billing_period = $3, active_until = $4 +ON CONFLICT (user_id) DO UPDATE SET plan_id = $2, billing_period = $3, active_until = $4, updated_at = now() ` type UpsertEntitlementParams struct { @@ -6635,28 +7244,37 @@ INSERT INTO store_products ( period, price_minor, currency_code, + trial_days, + trial_interval, + trial_interval_count, is_active ) VALUES ( - $1, $2, $3, $4, $5, $6, $7 + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10 ) ON CONFLICT (provider, store_product_id) DO UPDATE SET plan_id = EXCLUDED.plan_id, period = EXCLUDED.period, price_minor = EXCLUDED.price_minor, currency_code = EXCLUDED.currency_code, + trial_days = EXCLUDED.trial_days, + trial_interval = EXCLUDED.trial_interval, + trial_interval_count = EXCLUDED.trial_interval_count, is_active = EXCLUDED.is_active, updated_at = now() -RETURNING id, plan_id, provider, store_product_id, period, price_minor, currency_code, is_active, created_at, updated_at +RETURNING id, plan_id, provider, store_product_id, period, price_minor, currency_code, trial_days, trial_interval, trial_interval_count, is_active, created_at, updated_at ` type UpsertStoreProductByProviderAndProductIDParams struct { - PlanID string `json:"plan_id"` - Provider string `json:"provider"` - StoreProductID string `json:"store_product_id"` - Period string `json:"period"` - PriceMinor *int64 `json:"price_minor"` - CurrencyCode *string `json:"currency_code"` - IsActive bool `json:"is_active"` + PlanID string `json:"plan_id"` + Provider string `json:"provider"` + StoreProductID string `json:"store_product_id"` + Period string `json:"period"` + PriceMinor *int64 `json:"price_minor"` + CurrencyCode *string `json:"currency_code"` + TrialDays int32 `json:"trial_days"` + TrialInterval string `json:"trial_interval"` + TrialIntervalCount int32 `json:"trial_interval_count"` + IsActive bool `json:"is_active"` } func (q *Queries) UpsertStoreProductByProviderAndProductID(ctx context.Context, arg UpsertStoreProductByProviderAndProductIDParams) (StoreProduct, error) { @@ -6667,6 +7285,9 @@ func (q *Queries) UpsertStoreProductByProviderAndProductID(ctx context.Context, arg.Period, arg.PriceMinor, arg.CurrencyCode, + arg.TrialDays, + arg.TrialInterval, + arg.TrialIntervalCount, arg.IsActive, ) var i StoreProduct @@ -6678,6 +7299,9 @@ func (q *Queries) UpsertStoreProductByProviderAndProductID(ctx context.Context, &i.Period, &i.PriceMinor, &i.CurrencyCode, + &i.TrialDays, + &i.TrialInterval, + &i.TrialIntervalCount, &i.IsActive, &i.CreatedAt, &i.UpdatedAt, @@ -6708,6 +7332,90 @@ func (q *Queries) UpsertStripeCustomer(ctx context.Context, arg UpsertStripeCust return i, err } +const upsertSubscriptionByProviderID = `-- name: UpsertSubscriptionByProviderID :one +INSERT INTO subscriptions ( + user_id, plan_id, product_id, provider, billing_period, provider_subscription_id, status, + billing_anchor_day, billing_timezone, grace_days, grace_until, past_due_since, + current_period_start, current_period_end +) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) +ON CONFLICT (provider, provider_subscription_id) WHERE provider_subscription_id IS NOT NULL +DO UPDATE SET + user_id = EXCLUDED.user_id, + plan_id = EXCLUDED.plan_id, + product_id = EXCLUDED.product_id, + billing_period = EXCLUDED.billing_period, + status = EXCLUDED.status, + billing_anchor_day = EXCLUDED.billing_anchor_day, + billing_timezone = EXCLUDED.billing_timezone, + grace_days = EXCLUDED.grace_days, + grace_until = EXCLUDED.grace_until, + past_due_since = EXCLUDED.past_due_since, + current_period_start = EXCLUDED.current_period_start, + current_period_end = EXCLUDED.current_period_end, + cancel_at_period_end = false, + updated_at = now() +RETURNING id, user_id, plan_id, product_id, provider, billing_period, provider_subscription_id, status, current_period_start, current_period_end, cancel_at_period_end, created_at, updated_at, billing_anchor_day, billing_timezone, grace_days, grace_until, past_due_since +` + +type UpsertSubscriptionByProviderIDParams struct { + UserID pgtype.UUID `json:"user_id"` + PlanID string `json:"plan_id"` + ProductID pgtype.UUID `json:"product_id"` + Provider string `json:"provider"` + BillingPeriod string `json:"billing_period"` + ProviderSubscriptionID *string `json:"provider_subscription_id"` + Status string `json:"status"` + BillingAnchorDay *int32 `json:"billing_anchor_day"` + BillingTimezone *string `json:"billing_timezone"` + GraceDays int32 `json:"grace_days"` + GraceUntil pgtype.Timestamptz `json:"grace_until"` + PastDueSince pgtype.Timestamptz `json:"past_due_since"` + CurrentPeriodStart pgtype.Timestamptz `json:"current_period_start"` + CurrentPeriodEnd pgtype.Timestamptz `json:"current_period_end"` +} + +func (q *Queries) UpsertSubscriptionByProviderID(ctx context.Context, arg UpsertSubscriptionByProviderIDParams) (Subscription, error) { + row := q.db.QueryRow(ctx, upsertSubscriptionByProviderID, + arg.UserID, + arg.PlanID, + arg.ProductID, + arg.Provider, + arg.BillingPeriod, + arg.ProviderSubscriptionID, + arg.Status, + arg.BillingAnchorDay, + arg.BillingTimezone, + arg.GraceDays, + arg.GraceUntil, + arg.PastDueSince, + arg.CurrentPeriodStart, + arg.CurrentPeriodEnd, + ) + var i Subscription + err := row.Scan( + &i.ID, + &i.UserID, + &i.PlanID, + &i.ProductID, + &i.Provider, + &i.BillingPeriod, + &i.ProviderSubscriptionID, + &i.Status, + &i.CurrentPeriodStart, + &i.CurrentPeriodEnd, + &i.CancelAtPeriodEnd, + &i.CreatedAt, + &i.UpdatedAt, + &i.BillingAnchorDay, + &i.BillingTimezone, + &i.GraceDays, + &i.GraceUntil, + &i.PastDueSince, + ) + return i, err +} + const upsertTransactionFromSync = `-- name: UpsertTransactionFromSync :one INSERT INTO transactions (id, user_id, category_id, balance_id, type, currency, merchant, note, raw_query, @@ -6730,7 +7438,7 @@ ON CONFLICT (id) DO UPDATE SET version = EXCLUDED.version, updated_at = EXCLUDED.updated_at WHERE transactions.version < EXCLUDED.version -RETURNING id, user_id, category_id, balance_id, batch_id, type, currency, merchant, note, raw_query, occurred_at, source, version, created_at, updated_at, deleted_at, encrypted_amount, encrypted_merchant, encrypted_note, encrypted_raw_query, orphaned_at, debt_id +RETURNING id, user_id, category_id, balance_id, batch_id, type, currency, merchant, note, raw_query, occurred_at, source, version, created_at, updated_at, deleted_at, debt_event_id, include_in_analytics, encrypted_amount, encrypted_merchant, encrypted_note, encrypted_raw_query, orphaned_at, debt_id ` type UpsertTransactionFromSyncParams struct { @@ -6796,6 +7504,8 @@ func (q *Queries) UpsertTransactionFromSync(ctx context.Context, arg UpsertTrans &i.CreatedAt, &i.UpdatedAt, &i.DeletedAt, + &i.DebtEventID, + &i.IncludeInAnalytics, &i.EncryptedAmount, &i.EncryptedMerchant, &i.EncryptedNote, @@ -6805,3 +7515,18 @@ func (q *Queries) UpsertTransactionFromSync(ctx context.Context, arg UpsertTrans ) return i, err } + +const userHasPolarBillingRecords = `-- name: UserHasPolarBillingRecords :one +SELECT EXISTS ( + SELECT 1 FROM subscriptions WHERE subscriptions.user_id = $1 AND subscriptions.provider = 'polar' + UNION ALL + SELECT 1 FROM purchases WHERE purchases.user_id = $1 AND purchases.provider = 'polar' +) +` + +func (q *Queries) UserHasPolarBillingRecords(ctx context.Context, userID pgtype.UUID) (bool, error) { + row := q.db.QueryRow(ctx, userHasPolarBillingRecords, userID) + var exists bool + err := row.Scan(&exists) + return exists, err +} diff --git a/internal/db/queries/query_sql_test.go b/internal/db/queries/query_sql_test.go new file mode 100644 index 0000000..392f396 --- /dev/null +++ b/internal/db/queries/query_sql_test.go @@ -0,0 +1,12 @@ +package queries + +import ( + "strings" + "testing" +) + +func TestListEligibleGeminiKeysUsesDeterministicOrdering(t *testing.T) { + if !strings.Contains(listEligibleGeminiKeys, "ORDER BY total_tokens ASC, id ASC") { + t.Fatalf("ListEligibleGeminiKeys ordering = %q, want deterministic total_tokens/id order", listEligibleGeminiKeys) + } +} diff --git a/internal/db/query.sql b/internal/db/query.sql index 85f7314..0e73e60 100644 --- a/internal/db/query.sql +++ b/internal/db/query.sql @@ -127,6 +127,20 @@ INSERT INTO transactions ( VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) RETURNING *; +-- name: CreateDebtBundleTransaction :one +INSERT INTO transactions ( + user_id, category_id, balance_id, type, currency, merchant, note, raw_query, + encrypted_amount, encrypted_merchant, encrypted_note, encrypted_raw_query, + occurred_at, source, debt_id, debt_event_id, include_in_analytics +) +VALUES ( + sqlc.arg(user_id), sqlc.arg(category_id), sqlc.arg(balance_id), sqlc.arg(type), sqlc.arg(currency), + sqlc.arg(merchant), sqlc.arg(note), sqlc.arg(raw_query), + sqlc.arg(encrypted_amount), sqlc.arg(encrypted_merchant), sqlc.arg(encrypted_note), sqlc.arg(encrypted_raw_query), + NOW(), sqlc.arg(source), sqlc.arg(debt_id), sqlc.arg(debt_event_id), sqlc.arg(include_in_analytics) +) +RETURNING *; + -- name: UpdateTransaction :one UPDATE transactions SET category_id = COALESCE(NULLIF(sqlc.arg(category_id)::UUID, '00000000-0000-0000-0000-000000000000'::UUID), category_id), @@ -178,6 +192,18 @@ FROM categories c LEFT JOIN category_translations ct ON ct.category_id = c.id AND ct.lang = $3 WHERE c.id = $1 AND (c.user_id = $2 OR c.user_id IS NULL) AND c.deleted_at IS NULL; +-- name: GetDebtBundleCategoryID :one +SELECT id +FROM categories +WHERE (user_id = $1 OR user_id IS NULL) + AND deleted_at IS NULL + AND title = 'Debts' +ORDER BY + CASE WHEN user_id IS NULL THEN 0 ELSE 1 END, + sort_order ASC, + created_at ASC +LIMIT 1; + -- name: CreateCategory :one INSERT INTO categories (user_id, title, icon_id, color_token, sort_order) VALUES ($1, $2, $3, $4, $5) @@ -255,8 +281,12 @@ LEFT JOIN balance_translations bt ON bt.balance_id = b.id AND bt.lang = $3 WHERE b.id = $1 AND b.user_id = $2 AND b.deleted_at IS NULL; -- name: CreateBalance :one -INSERT INTO balances (user_id, name, description, currency, initial_amount_minor, color_token, is_system, sort_order) -VALUES ($1, $2, $3, $4, $5, $6, $7, $8) +INSERT INTO balances ( + user_id, name, description, currency, initial_amount_minor, + color_token, is_system, sort_order, + encrypted_name, encrypted_description, encrypted_initial_amount +) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING *; -- name: UpdateBalance :one @@ -398,6 +428,14 @@ WHERE is_active = true ORDER BY total_tokens ASC LIMIT 1; +-- name: ListEligibleGeminiKeys :many +SELECT id, api_key FROM ai_credentials +WHERE is_active = true + AND provider = 'gemini' + AND (token_limit - total_tokens) > 2048 + AND requests_today < 20 +ORDER BY total_tokens ASC, id ASC; + -- name: TrackAIKeyUsage :exec UPDATE ai_credentials SET input_tokens = input_tokens + $2, @@ -431,6 +469,15 @@ UPDATE users SET WHERE id = $1 AND deleted_at IS NULL RETURNING *; +-- name: UpdateUserOnboardingContext :exec +UPDATE users +SET + financial_goal = COALESCE(sqlc.narg('financial_goal'), financial_goal), + main_challenge = COALESCE(sqlc.narg('main_challenge'), main_challenge), + experience_level = COALESCE(sqlc.narg('experience_level'), experience_level), + updated_at = CURRENT_TIMESTAMP +WHERE id = @id AND deleted_at IS NULL; + -- name: RecordUserDowngrade :exec UPDATE users SET last_downgraded_at = now(), @@ -737,6 +784,16 @@ WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL; UPDATE balances SET deleted_at = NOW(), updated_at = NOW() WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL; +-- name: UpdateBalanceSnapshotFromSync :exec +UPDATE balances +SET encrypted_balance_snapshot = $3, + snapshot_tx_count = $4, + snapshot_updated_at = $5, + updated_at = CURRENT_TIMESTAMP +WHERE id = $1 + AND user_id = $2 + AND deleted_at IS NULL; + -- ============================================================ -- Account Deletion Queries -- ============================================================ @@ -756,6 +813,68 @@ WHERE id = $1; -- name: GetUsersPendingDeletion :many SELECT id FROM users WHERE deletion_scheduled_at <= NOW(); +-- name: UserHasPolarBillingRecords :one +SELECT EXISTS ( + SELECT 1 FROM subscriptions WHERE subscriptions.user_id = sqlc.arg(user_id) AND subscriptions.provider = 'polar' + UNION ALL + SELECT 1 FROM purchases WHERE purchases.user_id = sqlc.arg(user_id) AND purchases.provider = 'polar' +); + +-- name: DeletePaymeCardsByUserID :exec +DELETE FROM payme_cards WHERE user_id = $1; + +-- name: DeleteSubscriptionsByUserID :exec +DELETE FROM subscriptions WHERE user_id = $1; + +-- name: DeletePurchasesByUserID :exec +DELETE FROM purchases WHERE user_id = $1; + +-- name: DeleteStripeCustomerByUserID :exec +DELETE FROM stripe_customers WHERE user_id = $1; + +-- name: DeleteEntitlementByUserID :exec +DELETE FROM entitlements WHERE user_id = $1; + +-- name: DeleteUserForAccountDeletion :exec +DELETE FROM users WHERE id = $1; + +-- ============================================================ +-- Start Fresh private-data reset queries +-- ============================================================ + +-- name: DeleteTransactionsForStartFresh :exec +DELETE FROM transactions WHERE user_id = $1; + +-- name: DeleteUserCategoriesForStartFresh :exec +DELETE FROM categories WHERE user_id = $1; + +-- name: DeleteBalancesForStartFresh :exec +DELETE FROM balances WHERE user_id = $1; + +-- name: DeleteDebtsForStartFresh :exec +DELETE FROM debts WHERE user_id = $1; + +-- name: DeleteDebtEventsForStartFresh :exec +DELETE FROM debt_events WHERE user_id = $1; + +-- name: DeleteDebtEventSplitsForStartFresh :exec +DELETE FROM debt_event_splits WHERE user_id = $1; + +-- name: DeleteUsageCountersForStartFresh :exec +DELETE FROM usage_counters WHERE user_id = $1; + +-- name: ClearUserEncryptionStateForStartFresh :exec +UPDATE users +SET public_key = NULL, + encrypted_dek_backup = NULL, + encrypted_privkey_backup = NULL, + key_created_at = NULL, + has_encryption_keys = FALSE, + encrypted_context = NULL, + encrypted_context_summary = NULL, + updated_at = NOW() +WHERE id = $1; + -- name: GetUserByID :one SELECT * FROM users WHERE id = $1 AND deleted_at IS NULL; @@ -827,6 +946,31 @@ INSERT INTO subscriptions ( VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) RETURNING *; +-- name: UpsertSubscriptionByProviderID :one +INSERT INTO subscriptions ( + user_id, plan_id, product_id, provider, billing_period, provider_subscription_id, status, + billing_anchor_day, billing_timezone, grace_days, grace_until, past_due_since, + current_period_start, current_period_end +) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) +ON CONFLICT (provider, provider_subscription_id) WHERE provider_subscription_id IS NOT NULL +DO UPDATE SET + user_id = EXCLUDED.user_id, + plan_id = EXCLUDED.plan_id, + product_id = EXCLUDED.product_id, + billing_period = EXCLUDED.billing_period, + status = EXCLUDED.status, + billing_anchor_day = EXCLUDED.billing_anchor_day, + billing_timezone = EXCLUDED.billing_timezone, + grace_days = EXCLUDED.grace_days, + grace_until = EXCLUDED.grace_until, + past_due_since = EXCLUDED.past_due_since, + current_period_start = EXCLUDED.current_period_start, + current_period_end = EXCLUDED.current_period_end, + cancel_at_period_end = false, + updated_at = now() +RETURNING *; + -- name: GetActiveSubscriptionByUserID :one SELECT * FROM subscriptions WHERE user_id = $1 AND status IN ('active', 'past_due') @@ -866,7 +1010,7 @@ SELECT * FROM subscriptions WHERE provider = $1 AND provider_subscription_id = $ -- name: UpsertEntitlement :exec INSERT INTO entitlements (user_id, plan_id, billing_period, active_until) VALUES ($1, $2, $3, $4) -ON CONFLICT (user_id) DO UPDATE SET plan_id = $2, billing_period = $3, active_until = $4; +ON CONFLICT (user_id) DO UPDATE SET plan_id = $2, billing_period = $3, active_until = $4, updated_at = now(); -- name: GetEntitlementByUserID :one SELECT e.*, p.features @@ -887,58 +1031,6 @@ SELECT * FROM payme_cards WHERE user_id = $1 AND is_active = true ORDER BY creat -- name: DeactivatePaymeCardsByUserID :exec UPDATE payme_cards SET is_active = false WHERE user_id = $1; --- ============================================================ --- Billing Job Queries --- ============================================================ - --- name: CreateBillingJob :one -INSERT INTO billing_jobs (subscription_id, user_id, amount_minor, currency_code, run_at) -VALUES ($1, $2, $3, $4, $5) RETURNING *; - --- name: CreateDowngradeCleanupJob :one -INSERT INTO downgrade_cleanup_jobs (user_id, reason, run_at) -VALUES ($1, $2, $3) -RETURNING *; - --- name: ClaimPendingDowngradeCleanupJobs :many -UPDATE downgrade_cleanup_jobs -SET status = 'claimed', claimed_at = now() -WHERE id IN ( - SELECT id - FROM downgrade_cleanup_jobs - WHERE status = 'pending' AND run_at <= now() - ORDER BY run_at ASC - FOR UPDATE SKIP LOCKED -) -RETURNING *; - --- name: CompleteDowngradeCleanupJob :exec -UPDATE downgrade_cleanup_jobs -SET status = 'completed', completed_at = now() -WHERE id = $1; - --- name: FailDowngradeCleanupJob :exec -UPDATE downgrade_cleanup_jobs -SET status = CASE WHEN retry_count + 1 >= max_retries THEN 'failed' ELSE 'pending' END, - retry_count = retry_count + 1, - error_message = $2, - run_at = CASE WHEN retry_count + 1 >= max_retries THEN run_at ELSE now() + interval '5 minutes' END -WHERE id = $1; - --- name: ClaimPendingBillingJobs :many -UPDATE billing_jobs SET status = 'claimed', claimed_at = now() -WHERE status = 'pending' AND run_at <= now() -RETURNING *; - --- name: CompleteBillingJob :exec -UPDATE billing_jobs SET status = 'completed', completed_at = now() WHERE id = $1; - --- name: FailBillingJob :exec -UPDATE billing_jobs SET status = CASE WHEN retry_count + 1 >= max_retries THEN 'exhausted' ELSE 'failed' END, - retry_count = retry_count + 1, error_message = $2, - run_at = CASE WHEN retry_count + 1 >= max_retries THEN run_at ELSE now() + (interval '1 hour' * power(2, retry_count)) END -WHERE id = $1; - -- ============================================================ -- Stripe Customer Queries -- ============================================================ @@ -971,15 +1063,21 @@ INSERT INTO store_products ( period, price_minor, currency_code, + trial_days, + trial_interval, + trial_interval_count, is_active ) VALUES ( - $1, $2, $3, $4, $5, $6, $7 + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10 ) ON CONFLICT (provider, store_product_id) DO UPDATE SET plan_id = EXCLUDED.plan_id, period = EXCLUDED.period, price_minor = EXCLUDED.price_minor, currency_code = EXCLUDED.currency_code, + trial_days = EXCLUDED.trial_days, + trial_interval = EXCLUDED.trial_interval, + trial_interval_count = EXCLUDED.trial_interval_count, is_active = EXCLUDED.is_active, updated_at = now() RETURNING *; @@ -1072,6 +1170,105 @@ WHERE user_id = $1 AND status = 'open' ORDER BY created_at DESC LIMIT 20; +-- name: CreateDebtEvent :one +INSERT INTO debt_events ( + user_id, debt_id, kind, direction, amount_minor, currency, + impact_amount_minor, impact_currency, exchange_rate, exchange_rate_date, + include_in_analytics, merchant, encrypted_merchant, note, encrypted_note, + source, idempotency_key +) VALUES ( + $1, $2, $3, $4, $5, $6, + $7, $8, $9, $10, + $11, $12, $13, $14, $15, + $16, $17 +) RETURNING *; + +-- name: CreateDebtEventSplit :one +INSERT INTO debt_event_splits ( + user_id, debt_event_id, transaction_id, balance_id, amount_minor, currency +) VALUES ($1, $2, $3, $4, $5, $6) +RETURNING *; + +-- name: GetDebtEventsByDebtID :many +SELECT * FROM debt_events +WHERE user_id = $1 AND debt_id = $2 AND deleted_at IS NULL +ORDER BY created_at ASC; + +-- name: GetDebtEventByID :one +SELECT * FROM debt_events +WHERE user_id = $1 AND id = $2 AND deleted_at IS NULL; + +-- name: UpdateDebtEvent :one +UPDATE debt_events +SET amount_minor = $3, + currency = $4, + impact_amount_minor = $5, + impact_currency = $6, + exchange_rate = $7, + exchange_rate_date = $8, + include_in_analytics = $9, + merchant = $10, + encrypted_merchant = $11, + note = $12, + encrypted_note = $13, + updated_at = now() +WHERE user_id = $1 AND id = $2 AND deleted_at IS NULL +RETURNING *; + +-- name: GetDebtEventSplitsByEventID :many +SELECT * FROM debt_event_splits +WHERE user_id = $1 AND debt_event_id = $2 AND deleted_at IS NULL +ORDER BY created_at ASC; + +-- name: SoftDeleteDebtEventSplitsByEventID :exec +UPDATE debt_event_splits +SET deleted_at = now(), updated_at = now() +WHERE user_id = $1 AND debt_event_id = $2 AND deleted_at IS NULL; + +-- name: SoftDeleteTransactionsByDebtEventID :exec +UPDATE transactions +SET deleted_at = now(), updated_at = now() +WHERE user_id = $1 AND debt_event_id = $2 AND deleted_at IS NULL; + +-- name: RelinkDebtBundleTransaction :one +UPDATE transactions +SET debt_id = $3, + debt_event_id = $4, + include_in_analytics = $5, + encrypted_amount = $6, + merchant = $7, + encrypted_merchant = $8, + note = $9, + encrypted_note = $10, + raw_query = $11, + encrypted_raw_query = $12, + updated_at = now() +WHERE user_id = $1 AND id = $2 AND deleted_at IS NULL +RETURNING *; + +-- name: UpdateDebtEncryptedFields :one +UPDATE debts +SET counterparty = $3, + encrypted_counterparty = $4, + note = $5, + encrypted_note = $6, + updated_at = now() +WHERE user_id = $1 AND id = $2 AND deleted_at IS NULL +RETURNING *; + +-- name: SoftDeleteDebtEvent :exec +UPDATE debt_events +SET deleted_at = now(), updated_at = now() +WHERE user_id = $1 AND id = $2 AND deleted_at IS NULL; + +-- name: UpdateDebtRemainingAndStatus :exec +UPDATE debts +SET amount_minor_remaining = $3, + overpaid_amount_minor = $4, + status = $5, + updated_at = now() +WHERE user_id = $1 AND id = $2; + -- name: CountDailyVoiceSubmissionsByUserID :one SELECT COUNT(*)::INT AS count FROM parse_attempts @@ -1181,7 +1378,7 @@ ON CONFLICT (user_id) DO UPDATE RETURNING *; -- name: HardDeleteUser :exec -UPDATE users SET deleted_at = NOW() WHERE id = $1; +DELETE FROM users WHERE id = $1; -- name: AdminDashboardUserStats :one SELECT @@ -1212,12 +1409,6 @@ SELECT FROM parse_attempts WHERE created_at >= CURRENT_DATE; --- name: AdminDashboardBillingJobStats :one -SELECT - COUNT(*) FILTER (WHERE status = 'pending') AS pending, - COUNT(*) FILTER (WHERE status = 'failed') AS failed -FROM billing_jobs; - -- name: ListAICredentials :many SELECT * FROM ai_credentials ORDER BY created_at DESC; @@ -1269,23 +1460,6 @@ WHERE ($1::text = '' OR s.status = $1) ORDER BY s.created_at DESC LIMIT $3 OFFSET $4; --- name: ListBillingJobsAdmin :many -SELECT bj.id, bj.subscription_id, bj.user_id, bj.amount_minor, bj.currency_code, - bj.status, bj.run_at, bj.claimed_at, bj.completed_at, bj.error_message, - bj.retry_count, bj.max_retries, bj.created_at, - u.email AS user_email -FROM billing_jobs bj -JOIN users u ON u.id = bj.user_id -WHERE ($1::text = '' OR bj.status = $1) -ORDER BY bj.run_at DESC -LIMIT $2 OFFSET $3; - --- name: RetryBillingJob :one -UPDATE billing_jobs -SET status = 'pending', run_at = NOW(), error_message = NULL -WHERE id = $1 AND status IN ('failed', 'exhausted') -RETURNING *; - -- name: ListPurchasesAdmin :many SELECT p.id, p.user_id, p.provider, p.store_product_id, p.store_transaction_id, p.status, p.purchased_at, p.expires_at, p.created_at, diff --git a/internal/db/schema.sql b/internal/db/schema.sql index 35fb570..7286670 100644 --- a/internal/db/schema.sql +++ b/internal/db/schema.sql @@ -28,9 +28,16 @@ CREATE TABLE IF NOT EXISTS users ( last_activity TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP NOT NULL, created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP NOT NULL, updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP NOT NULL, - deleted_at TIMESTAMPTZ NULL + deleted_at TIMESTAMPTZ NULL, + financial_goal VARCHAR(30) NULL CHECK (financial_goal IN ('track_spending', 'save_money', 'clear_debt')), + main_challenge VARCHAR(30) NULL CHECK (main_challenge IN ('forget_to_log', 'overspend', 'no_savings_habit')), + experience_level VARCHAR(30) NULL CHECK (experience_level IN ('first_time', 'tried_quit', 'used_regularly')) ); +ALTER TABLE users ADD COLUMN IF NOT EXISTS financial_goal VARCHAR(30) NULL CHECK (financial_goal IN ('track_spending', 'save_money', 'clear_debt')); +ALTER TABLE users ADD COLUMN IF NOT EXISTS main_challenge VARCHAR(30) NULL CHECK (main_challenge IN ('forget_to_log', 'overspend', 'no_savings_habit')); +ALTER TABLE users ADD COLUMN IF NOT EXISTS experience_level VARCHAR(30) NULL CHECK (experience_level IN ('first_time', 'tried_quit', 'used_regularly')); + CREATE INDEX IF NOT EXISTS idx_users_currency_code ON users (currency); @@ -164,12 +171,19 @@ CREATE TABLE IF NOT EXISTS store_products ( period VARCHAR(25) NOT NULL CHECK (period IN ('weekly', 'monthly', 'quarterly', 'semiannual', 'yearly')), price_minor BIGINT, currency_code CHAR(3) DEFAULT 'USD', + trial_days INT NOT NULL DEFAULT 0, + trial_interval TEXT NOT NULL DEFAULT '', + trial_interval_count INT NOT NULL DEFAULT 0, is_active BOOLEAN DEFAULT true NOT NULL, created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP NOT NULL, updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP NOT NULL, UNIQUE (provider, store_product_id) ); +ALTER TABLE store_products ADD COLUMN IF NOT EXISTS trial_days INT NOT NULL DEFAULT 0; +ALTER TABLE store_products ADD COLUMN IF NOT EXISTS trial_interval TEXT NOT NULL DEFAULT ''; +ALTER TABLE store_products ADD COLUMN IF NOT EXISTS trial_interval_count INT NOT NULL DEFAULT 0; + CREATE INDEX IF NOT EXISTS idx_store_products_plan_id ON store_products(plan_id); @@ -211,6 +225,9 @@ CREATE TABLE IF NOT EXISTS subscriptions ( ); CREATE INDEX IF NOT EXISTS idx_subscriptions_user_id ON subscriptions(user_id); CREATE INDEX IF NOT EXISTS idx_subscriptions_status ON subscriptions(status); +CREATE UNIQUE INDEX IF NOT EXISTS idx_subscriptions_provider_subscription_id_unique +ON subscriptions(provider, provider_subscription_id) +WHERE provider_subscription_id IS NOT NULL; -- Tokenized Payme cards for recurring charges CREATE TABLE IF NOT EXISTS payme_cards ( @@ -224,24 +241,6 @@ CREATE TABLE IF NOT EXISTS payme_cards ( ); CREATE INDEX IF NOT EXISTS idx_payme_cards_user_id ON payme_cards(user_id); --- Recurring billing job queue for Payme subscriptions -CREATE TABLE IF NOT EXISTS billing_jobs ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - subscription_id UUID NOT NULL REFERENCES subscriptions(id), - user_id UUID NOT NULL REFERENCES users(id), - amount_minor BIGINT NOT NULL, - currency_code CHAR(3) NOT NULL DEFAULT 'UZS', - status VARCHAR(20) NOT NULL DEFAULT 'pending', -- pending, claimed, completed, failed, exhausted - run_at TIMESTAMPTZ NOT NULL, - claimed_at TIMESTAMPTZ, - completed_at TIMESTAMPTZ, - error_message TEXT, - retry_count INT NOT NULL DEFAULT 0, - max_retries INT NOT NULL DEFAULT 4, - created_at TIMESTAMPTZ NOT NULL DEFAULT now() -); -CREATE INDEX IF NOT EXISTS idx_billing_jobs_status_run_at ON billing_jobs(status, run_at); - -- Stripe customer ID mapping CREATE TABLE IF NOT EXISTS stripe_customers ( user_id UUID PRIMARY KEY REFERENCES users(id), @@ -416,8 +415,8 @@ CREATE TABLE IF NOT EXISTS debts ( currency CHAR(3) NOT NULL, note TEXT, encrypted_note TEXT, - status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'archived')), - source TEXT NOT NULL DEFAULT 'manual' CHECK (source IN ('voice', 'manual')), + status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'archived', 'settled')), + source TEXT NOT NULL DEFAULT 'manual' CHECK (source IN ('voice', 'manual', 'chat_manual', 'transaction_manual', 'debt_screen', 'convert')), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); @@ -426,6 +425,61 @@ CREATE INDEX IF NOT EXISTS idx_debts_user_id ON debts(user_id); CREATE INDEX IF NOT EXISTS idx_debts_user_status ON debts(user_id, status); CREATE INDEX IF NOT EXISTS idx_debts_user_created ON debts(user_id, created_at DESC); +ALTER TABLE transactions ADD COLUMN IF NOT EXISTS debt_event_id UUID; +ALTER TABLE transactions ADD COLUMN IF NOT EXISTS include_in_analytics BOOLEAN NOT NULL DEFAULT true; + +ALTER TABLE debts ADD COLUMN IF NOT EXISTS overpaid_amount_minor BIGINT NOT NULL DEFAULT 0; +ALTER TABLE debts ADD COLUMN IF NOT EXISTS sync_bundle_id UUID; + +ALTER TABLE debts DROP CONSTRAINT IF EXISTS debts_status_check; +ALTER TABLE debts ADD CONSTRAINT debts_status_check CHECK (status IN ('open', 'archived', 'settled')); +ALTER TABLE debts DROP CONSTRAINT IF EXISTS debts_source_check; +ALTER TABLE debts ADD CONSTRAINT debts_source_check CHECK (source IN ('voice', 'manual', 'chat_manual', 'transaction_manual', 'debt_screen', 'convert')); + +CREATE TABLE IF NOT EXISTS debt_events ( + id UUID PRIMARY KEY DEFAULT uuidv7(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + debt_id UUID NOT NULL REFERENCES debts(id) ON DELETE CASCADE, + kind VARCHAR(24) NOT NULL CHECK (kind IN ('creation', 'repayment', 'adjustment')), + direction VARCHAR(16) NOT NULL CHECK (direction IN ('lent', 'owed')), + amount_minor BIGINT NOT NULL CHECK (amount_minor > 0), + currency VARCHAR(3) NOT NULL REFERENCES currencies(code), + impact_amount_minor BIGINT NOT NULL CHECK (impact_amount_minor > 0), + impact_currency VARCHAR(3) NOT NULL REFERENCES currencies(code), + exchange_rate NUMERIC(20, 10), + exchange_rate_date TIMESTAMPTZ, + include_in_analytics BOOLEAN NOT NULL DEFAULT false, + merchant TEXT, + encrypted_merchant TEXT, + note TEXT, + encrypted_note TEXT, + source VARCHAR(24) NOT NULL DEFAULT 'manual', + idempotency_key TEXT, + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now(), + deleted_at TIMESTAMPTZ, + UNIQUE(user_id, idempotency_key) +); + +CREATE TABLE IF NOT EXISTS debt_event_splits ( + id UUID PRIMARY KEY DEFAULT uuidv7(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + debt_event_id UUID NOT NULL REFERENCES debt_events(id) ON DELETE CASCADE, + transaction_id UUID REFERENCES transactions(id) ON DELETE SET NULL, + balance_id UUID REFERENCES balances(id) ON DELETE SET NULL, + amount_minor BIGINT NOT NULL CHECK (amount_minor > 0), + currency VARCHAR(3) NOT NULL REFERENCES currencies(code), + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now(), + deleted_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_debt_events_user_debt ON debt_events(user_id, debt_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_debt_events_idempotency ON debt_events(user_id, idempotency_key) WHERE idempotency_key IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_debt_event_splits_event ON debt_event_splits(debt_event_id); +CREATE INDEX IF NOT EXISTS idx_transactions_debt_event ON transactions(debt_event_id) WHERE debt_event_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_transactions_analytics ON transactions(include_in_analytics, occurred_at); + -- ============================================================ -- Encryption + Offline-First Architecture -- ============================================================ @@ -510,26 +564,15 @@ CREATE INDEX IF NOT EXISTS idx_balances_orphaned ON balances (orphaned_at) WHERE -- Data leak prevention: drop stored response bodies from idempotency cache ALTER TABLE idempotency_keys DROP COLUMN IF EXISTS response_body; +-- Queue systems removed; drop legacy tables if they exist. +DROP TABLE IF EXISTS jobs; +DROP TABLE IF EXISTS billing_jobs; +DROP TABLE IF EXISTS downgrade_cleanup_jobs; + -- Debt repayment linking on transactions ALTER TABLE transactions ADD COLUMN IF NOT EXISTS debt_id UUID REFERENCES debts(id) ON DELETE SET NULL; CREATE INDEX IF NOT EXISTS idx_transactions_debt_id ON transactions(debt_id) WHERE debt_id IS NOT NULL; -CREATE TABLE IF NOT EXISTS downgrade_cleanup_jobs ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - reason VARCHAR(32) NOT NULL, - status VARCHAR(20) NOT NULL DEFAULT 'pending', - run_at TIMESTAMPTZ NOT NULL DEFAULT now(), - claimed_at TIMESTAMPTZ, - completed_at TIMESTAMPTZ, - error_message TEXT, - retry_count INT NOT NULL DEFAULT 0, - max_retries INT NOT NULL DEFAULT 5, - created_at TIMESTAMPTZ NOT NULL DEFAULT now() -); -CREATE INDEX IF NOT EXISTS idx_downgrade_cleanup_jobs_status_run_at -ON downgrade_cleanup_jobs(status, run_at); - -- Admin credential-based auth (independent of user OAuth) CREATE TABLE IF NOT EXISTS admin_sessions ( id UUID PRIMARY KEY DEFAULT uuidv7(), diff --git a/internal/handlers/account.go b/internal/handlers/account.go index 404420a..6e66533 100644 --- a/internal/handlers/account.go +++ b/internal/handlers/account.go @@ -1,27 +1,88 @@ package handlers import ( + "context" + "encoding/json" + "fmt" "net/http" + "time" + + "numex-api/internal/clients" "numex-api/internal/config" + "numex-api/internal/db/queries" "numex-api/internal/msg" + "github.com/jackc/pgx/v5/pgtype" "github.com/labstack/echo/v4" "google.golang.org/api/idtoken" ) -// RequestAccountDeletionHandler handles DELETE /api/v1/user/account -// Schedules account deletion in 30 days. User can cancel by logging in again. -// Requires re-authentication via fresh Google ID token for security. +var getUserFromClaimsForAccountDeletion = func(s *Server, c echo.Context, op string) (queries.User, error) { + return s.getUserFromClaims(c, op) +} + +var validateGoogleIDTokenForAccountDeletion = idtoken.Validate + +var nowForAccountDeletion = time.Now + +var userHasPolarBillingRecordsForAccountDeletion = func(ctx context.Context, q *queries.Queries, userID pgtype.UUID) (bool, error) { + return q.UserHasPolarBillingRecords(ctx, userID) +} + +var deletePolarCustomerForAccountDeletion = func(ctx context.Context, polar *clients.PolarClient, externalID string, anonymize bool) error { + return polar.DeleteCustomerByExternalID(ctx, externalID, anonymize) +} + +var purgeUserLocalForAccountDeletion = func(ctx context.Context, s *Server, userID pgtype.UUID) error { + return s.purgeUserLocalForAccountDeletion(ctx, userID) +} + +var resetUserPrivateDataForStartFresh = func(ctx context.Context, s *Server, userID pgtype.UUID) error { + return s.resetUserPrivateDataForStartFresh(ctx, userID) +} + +type startFreshResetQueries interface { + DeleteDebtEventSplitsForStartFresh(context.Context, pgtype.UUID) error + DeleteDebtEventsForStartFresh(context.Context, pgtype.UUID) error + DeleteDebtsForStartFresh(context.Context, pgtype.UUID) error + DeleteTransactionsForStartFresh(context.Context, pgtype.UUID) error + DeleteUserCategoriesForStartFresh(context.Context, pgtype.UUID) error + DeleteBalancesForStartFresh(context.Context, pgtype.UUID) error + DeleteUsageCountersForStartFresh(context.Context, pgtype.UUID) error + PurgeParseAttemptsResults(context.Context, pgtype.UUID) error + ClearUserEncryptionStateForStartFresh(context.Context, pgtype.UUID) error +} + +// StartFreshHandler handles POST /api/v1/user/start-fresh. +// It clears only private finance data while preserving account and billing state. +func (S *Server) StartFreshHandler(c echo.Context) error { + ctx := c.Request().Context() + const op = "StartFresh" + + user, err := getUserFromClaimsForAccountDeletion(S, c, op) + if err != nil { + return c.JSON(http.StatusUnauthorized, map[string]string{"message": msg.ErrInvalidOrExpiredAccessToken}) + } + + if err := resetUserPrivateDataForStartFresh(ctx, S, user.ID); err != nil { + S.LogErr(c, op, err) + return c.JSON(http.StatusInternalServerError, map[string]string{"message": msg.ErrInternalServerError}) + } + + return c.JSON(http.StatusOK, successResponse(msg.MsgDeleted)) +} + +// RequestAccountDeletionHandler handles DELETE /api/v1/user/account. +// It immediately deletes the account after fresh Google re-authentication. func (S *Server) RequestAccountDeletionHandler(c echo.Context) error { ctx := c.Request().Context() const op = "RequestAccountDeletion" - user, err := S.getUserFromClaims(c, op) + user, err := getUserFromClaimsForAccountDeletion(S, c, op) if err != nil { return c.JSON(http.StatusUnauthorized, map[string]string{"message": msg.ErrInvalidOrExpiredAccessToken}) } - // ── Re-authentication: require fresh Google ID token ───────────────── var req struct { IDToken string `json:"id_token" validate:"required"` } @@ -32,28 +93,172 @@ func (S *Server) RequestAccountDeletionHandler(c echo.Context) error { return c.JSON(http.StatusBadRequest, errResponse(msg.ErrReauthRequired, msg.CodeReauthRequired)) } - payload, err := idtoken.Validate(ctx, req.IDToken, config.EnVar.GoogleClientID) + payload, err := validateGoogleIDTokenForAccountDeletion(ctx, req.IDToken, config.EnVar.GoogleClientID) if err != nil { return c.JSON(http.StatusUnauthorized, errResponse(msg.ErrInvalidOrExpiredIDToken, msg.CodeReauthRequired)) } - // Confirm the ID token belongs to the same user sub, _ := payload.Claims["sub"].(string) if user.Sub == nil || sub != *user.Sub { return c.JSON(http.StatusForbidden, errResponse(msg.ErrForbidden, msg.CodeReauthRequired)) } + if !accountDeletionIDTokenIsFresh(payload, nowForAccountDeletion(), 5*time.Minute) { + return c.JSON(http.StatusUnauthorized, errResponse(msg.ErrReauthRequired, msg.CodeReauthRequired)) + } - // ── Schedule deletion ──────────────────────────────────────────────── - if err := S.Queries.RequestAccountDeletion(ctx, user.ID); err != nil { + hasPolarRecords, err := userHasPolarBillingRecordsForAccountDeletion(ctx, S.Queries, user.ID) + if err != nil { S.LogErr(c, op, err) return c.JSON(http.StatusInternalServerError, map[string]string{"message": msg.ErrInternalServerError}) } - // Revoke all sessions so the user is logged out everywhere - if err := S.Queries.DeleteAllRefreshTokensByUserID(ctx, user.ID); err != nil { + if S.Polar != nil { + if err := deletePolarCustomerForAccountDeletion(ctx, S.Polar, user.ID.String(), true); err != nil { + S.LogErr(c, op, err) + return c.JSON(http.StatusBadGateway, msgResponse(msg.ErrProviderUnavailable)) + } + } else if hasPolarRecords { + return c.JSON(http.StatusServiceUnavailable, msgResponse(msg.ErrProviderUnavailable)) + } + + if err := purgeUserLocalForAccountDeletion(ctx, S, user.ID); err != nil { S.LogErr(c, op, err) - // Non-fatal: deletion is still scheduled + return c.JSON(http.StatusInternalServerError, map[string]string{"message": msg.ErrInternalServerError}) + } + + return c.JSON(http.StatusOK, successResponse(msg.MsgDeleted)) +} + +func (S *Server) purgeUserLocalForAccountDeletion(ctx context.Context, userID pgtype.UUID) error { + if S.DB == nil { + return fmt.Errorf("account deletion purge: db not configured") + } + + tx, err := S.DB.Begin(ctx) + if err != nil { + return fmt.Errorf("account deletion purge: begin transaction: %w", err) } + defer func() { + _ = tx.Rollback(ctx) + }() - return c.JSON(http.StatusOK, successResponse(msg.MsgAccountDeletionRequested)) + qtx := queries.New(tx) + if err := qtx.DeletePaymeCardsByUserID(ctx, userID); err != nil { + return fmt.Errorf("delete payme cards: %w", err) + } + if err := qtx.DeleteSubscriptionsByUserID(ctx, userID); err != nil { + return fmt.Errorf("delete subscriptions: %w", err) + } + if err := qtx.DeletePurchasesByUserID(ctx, userID); err != nil { + return fmt.Errorf("delete purchases: %w", err) + } + if err := qtx.DeleteStripeCustomerByUserID(ctx, userID); err != nil { + return fmt.Errorf("delete stripe customer: %w", err) + } + if err := qtx.DeleteEntitlementByUserID(ctx, userID); err != nil { + return fmt.Errorf("delete entitlement: %w", err) + } + if err := qtx.DeleteUserForAccountDeletion(ctx, userID); err != nil { + return fmt.Errorf("delete user: %w", err) + } + + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("account deletion purge: commit transaction: %w", err) + } + return nil +} + +func (S *Server) resetUserPrivateDataForStartFresh(ctx context.Context, userID pgtype.UUID) error { + if S.DB == nil { + return fmt.Errorf("start fresh reset: db not configured") + } + + tx, err := S.DB.Begin(ctx) + if err != nil { + return fmt.Errorf("start fresh reset: begin transaction: %w", err) + } + defer func() { + _ = tx.Rollback(ctx) + }() + + qtx := queries.New(tx) + if err := resetUserPrivateDataForStartFreshWithQueries(ctx, qtx, userID); err != nil { + return err + } + + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("start fresh reset: commit transaction: %w", err) + } + return nil +} + +func resetUserPrivateDataForStartFreshWithQueries(ctx context.Context, q startFreshResetQueries, userID pgtype.UUID) error { + if err := q.DeleteDebtEventSplitsForStartFresh(ctx, userID); err != nil { + return fmt.Errorf("delete debt event splits: %w", err) + } + if err := q.DeleteDebtEventsForStartFresh(ctx, userID); err != nil { + return fmt.Errorf("delete debt events: %w", err) + } + if err := q.DeleteDebtsForStartFresh(ctx, userID); err != nil { + return fmt.Errorf("delete debts: %w", err) + } + if err := q.DeleteTransactionsForStartFresh(ctx, userID); err != nil { + return fmt.Errorf("delete transactions: %w", err) + } + if err := q.DeleteUserCategoriesForStartFresh(ctx, userID); err != nil { + return fmt.Errorf("delete user categories: %w", err) + } + if err := q.DeleteBalancesForStartFresh(ctx, userID); err != nil { + return fmt.Errorf("delete balances: %w", err) + } + if err := q.DeleteUsageCountersForStartFresh(ctx, userID); err != nil { + return fmt.Errorf("delete usage counters: %w", err) + } + if err := q.PurgeParseAttemptsResults(ctx, userID); err != nil { + return fmt.Errorf("purge parse attempts: %w", err) + } + if err := q.ClearUserEncryptionStateForStartFresh(ctx, userID); err != nil { + return fmt.Errorf("clear user encryption state: %w", err) + } + return nil +} + +func accountDeletionIDTokenIsFresh(payload *idtoken.Payload, now time.Time, maxAge time.Duration) bool { + if payload == nil { + return false + } + issuedAt, ok := claimUnixTime(payload.Claims, "auth_time") + if !ok { + issuedAt, ok = claimUnixTime(payload.Claims, "iat") + } + if !ok { + return false + } + if issuedAt.After(now.Add(30 * time.Second)) { + return false + } + return now.Sub(issuedAt) <= maxAge +} + +func claimUnixTime(claims map[string]any, key string) (time.Time, bool) { + raw, ok := claims[key] + if !ok { + return time.Time{}, false + } + switch value := raw.(type) { + case float64: + return time.Unix(int64(value), 0), true + case int64: + return time.Unix(value, 0), true + case int: + return time.Unix(int64(value), 0), true + case json.Number: + seconds, err := value.Int64() + if err != nil { + return time.Time{}, false + } + return time.Unix(seconds, 0), true + default: + return time.Time{}, false + } } diff --git a/internal/handlers/account_test.go b/internal/handlers/account_test.go new file mode 100644 index 0000000..0e9f690 --- /dev/null +++ b/internal/handlers/account_test.go @@ -0,0 +1,472 @@ +package handlers + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "numex-api/internal/clients" + "numex-api/internal/db/queries" + + "github.com/go-playground/validator/v10" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + "github.com/labstack/echo/v4" + "google.golang.org/api/idtoken" +) + +func TestRequestAccountDeletionHandler_MissingIDTokenRequiresReauth(t *testing.T) { + resetAccountDeletionTestHooks(t) + + getUserFromClaimsForAccountDeletion = func(_ *Server, _ echo.Context, _ string) (queries.User, error) { + return accountDeletionTestUser(), nil + } + + rec := runAccountDeletionRequest(t, &Server{Validate: validator.New()}, `{}`) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } + if !strings.Contains(rec.Body.String(), "REAUTH_REQUIRED") { + t.Fatalf("body = %s, want reauth code", rec.Body.String()) + } +} + +func TestRequestAccountDeletionHandler_MismatchedGoogleSubForbidden(t *testing.T) { + resetAccountDeletionTestHooks(t) + + getUserFromClaimsForAccountDeletion = func(_ *Server, _ echo.Context, _ string) (queries.User, error) { + return accountDeletionTestUser(), nil + } + validateGoogleIDTokenForAccountDeletion = func(context.Context, string, string) (*idtoken.Payload, error) { + return &idtoken.Payload{Claims: map[string]any{"sub": "other-sub"}}, nil + } + + rec := runAccountDeletionRequest(t, &Server{Validate: validator.New()}, `{"id_token":"fresh"}`) + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403", rec.Code) + } +} + +func TestRequestAccountDeletionHandler_StaleGoogleTokenRequiresReauth(t *testing.T) { + resetAccountDeletionTestHooks(t) + + getUserFromClaimsForAccountDeletion = func(_ *Server, _ echo.Context, _ string) (queries.User, error) { + return accountDeletionTestUser(), nil + } + nowForAccountDeletion = func() time.Time { + return time.Unix(1_700_000_000, 0) + } + validateGoogleIDTokenForAccountDeletion = func(context.Context, string, string) (*idtoken.Payload, error) { + return &idtoken.Payload{ + Claims: map[string]any{ + "sub": "google-sub", + "iat": float64(nowForAccountDeletion().Add(-10 * time.Minute).Unix()), + }, + }, nil + } + + rec := runAccountDeletionRequest(t, &Server{Validate: validator.New()}, `{"id_token":"stale"}`) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } + if !strings.Contains(rec.Body.String(), "REAUTH_REQUIRED") { + t.Fatalf("body = %s, want reauth code", rec.Body.String()) + } +} + +func TestRequestAccountDeletionHandler_PolarRecordsMissingClientDoesNotPurge(t *testing.T) { + resetAccountDeletionTestHooks(t) + + var purged bool + getUserFromClaimsForAccountDeletion = func(_ *Server, _ echo.Context, _ string) (queries.User, error) { + return accountDeletionTestUser(), nil + } + validateGoogleIDTokenForAccountDeletion = accountDeletionValidIDToken + userHasPolarBillingRecordsForAccountDeletion = func(context.Context, *queries.Queries, pgtype.UUID) (bool, error) { + return true, nil + } + purgeUserLocalForAccountDeletion = func(context.Context, *Server, pgtype.UUID) error { + purged = true + return nil + } + + rec := runAccountDeletionRequest(t, &Server{Validate: validator.New()}, `{"id_token":"fresh"}`) + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d, want 503", rec.Code) + } + if purged { + t.Fatal("local purge ran despite missing Polar client") + } +} + +func TestRequestAccountDeletionHandler_PolarDeleteErrorDoesNotPurge(t *testing.T) { + resetAccountDeletionTestHooks(t) + + var purged bool + getUserFromClaimsForAccountDeletion = func(_ *Server, _ echo.Context, _ string) (queries.User, error) { + return accountDeletionTestUser(), nil + } + validateGoogleIDTokenForAccountDeletion = accountDeletionValidIDToken + userHasPolarBillingRecordsForAccountDeletion = func(context.Context, *queries.Queries, pgtype.UUID) (bool, error) { + return true, nil + } + deletePolarCustomerForAccountDeletion = func(context.Context, *clients.PolarClient, string, bool) error { + return errors.New("polar down") + } + purgeUserLocalForAccountDeletion = func(context.Context, *Server, pgtype.UUID) error { + purged = true + return nil + } + + rec := runAccountDeletionRequest(t, &Server{Validate: validator.New(), Polar: &clients.PolarClient{}}, `{"id_token":"fresh"}`) + if rec.Code != http.StatusBadGateway { + t.Fatalf("status = %d, want 502", rec.Code) + } + if purged { + t.Fatal("local purge ran despite Polar delete error") + } +} + +func TestRequestAccountDeletionHandler_NoPolarRecordsPurgesLocally(t *testing.T) { + resetAccountDeletionTestHooks(t) + + var purged bool + getUserFromClaimsForAccountDeletion = func(_ *Server, _ echo.Context, _ string) (queries.User, error) { + return accountDeletionTestUser(), nil + } + validateGoogleIDTokenForAccountDeletion = accountDeletionValidIDToken + userHasPolarBillingRecordsForAccountDeletion = func(context.Context, *queries.Queries, pgtype.UUID) (bool, error) { + return false, nil + } + purgeUserLocalForAccountDeletion = func(context.Context, *Server, pgtype.UUID) error { + purged = true + return nil + } + + rec := runAccountDeletionRequest(t, &Server{Validate: validator.New()}, `{"id_token":"fresh"}`) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + if !purged { + t.Fatal("local purge did not run") + } +} + +func TestRequestAccountDeletionHandler_ConfiguredPolarDeletesEvenWithoutLocalRecords(t *testing.T) { + resetAccountDeletionTestHooks(t) + + var deletedPolar bool + var purged bool + getUserFromClaimsForAccountDeletion = func(_ *Server, _ echo.Context, _ string) (queries.User, error) { + return accountDeletionTestUser(), nil + } + validateGoogleIDTokenForAccountDeletion = accountDeletionValidIDToken + userHasPolarBillingRecordsForAccountDeletion = func(context.Context, *queries.Queries, pgtype.UUID) (bool, error) { + return false, nil + } + deletePolarCustomerForAccountDeletion = func(_ context.Context, _ *clients.PolarClient, externalID string, anonymize bool) error { + deletedPolar = true + if externalID != "00000000-0000-0000-0000-000000000123" { + t.Fatalf("externalID = %s, want user id", externalID) + } + if !anonymize { + t.Fatal("anonymize = false, want true") + } + return nil + } + purgeUserLocalForAccountDeletion = func(context.Context, *Server, pgtype.UUID) error { + purged = true + return nil + } + + rec := runAccountDeletionRequest(t, &Server{Validate: validator.New(), Polar: &clients.PolarClient{}}, `{"id_token":"fresh"}`) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + if !deletedPolar { + t.Fatal("configured Polar client was not asked to delete customer") + } + if !purged { + t.Fatal("local purge did not run") + } +} + +func TestRequestAccountDeletionHandler_FullDeleteRemovesSubscriptionAndEntitlementState(t *testing.T) { + resetAccountDeletionTestHooks(t) + + var subscriptionsRemoved bool + var entitlementRemoved bool + getUserFromClaimsForAccountDeletion = func(_ *Server, _ echo.Context, _ string) (queries.User, error) { + return accountDeletionTestUser(), nil + } + validateGoogleIDTokenForAccountDeletion = accountDeletionValidIDToken + userHasPolarBillingRecordsForAccountDeletion = func(context.Context, *queries.Queries, pgtype.UUID) (bool, error) { + return false, nil + } + purgeUserLocalForAccountDeletion = func(context.Context, *Server, pgtype.UUID) error { + // Real purge helper removes both rows before deleting the user. + subscriptionsRemoved = true + entitlementRemoved = true + return nil + } + resetUserPrivateDataForStartFresh = func(context.Context, *Server, pgtype.UUID) error { + t.Fatal("full delete must not use start fresh reset") + return nil + } + + rec := runAccountDeletionRequest(t, &Server{Validate: validator.New()}, `{"id_token":"fresh"}`) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + if !subscriptionsRemoved { + t.Fatal("full delete did not remove subscriptions") + } + if !entitlementRemoved { + t.Fatal("full delete did not remove entitlement") + } +} + +func TestStartFreshHandler_ResetsPrivateDataOnly(t *testing.T) { + resetAccountDeletionTestHooks(t) + + var reset bool + getUserFromClaimsForAccountDeletion = func(_ *Server, _ echo.Context, _ string) (queries.User, error) { + return accountDeletionTestUser(), nil + } + purgeUserLocalForAccountDeletion = func(context.Context, *Server, pgtype.UUID) error { + t.Fatal("start fresh must not use account deletion purge") + return nil + } + resetUserPrivateDataForStartFresh = func(context.Context, *Server, pgtype.UUID) error { + reset = true + return nil + } + + rec := runStartFreshRequest(t, &Server{}) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + if !reset { + t.Fatal("private data reset did not run") + } +} + +func TestStartFreshHandler_DoesNotRemoveSubscriptionOrEntitlementState(t *testing.T) { + resetAccountDeletionTestHooks(t) + + var reset bool + var subscriptionsRemoved bool + var entitlementRemoved bool + getUserFromClaimsForAccountDeletion = func(_ *Server, _ echo.Context, _ string) (queries.User, error) { + return accountDeletionTestUser(), nil + } + purgeUserLocalForAccountDeletion = func(context.Context, *Server, pgtype.UUID) error { + subscriptionsRemoved = true + entitlementRemoved = true + return nil + } + resetUserPrivateDataForStartFresh = func(context.Context, *Server, pgtype.UUID) error { + reset = true + return nil + } + + rec := runStartFreshRequest(t, &Server{}) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + if !reset { + t.Fatal("private data reset did not run") + } + if subscriptionsRemoved { + t.Fatal("start fresh removed subscriptions") + } + if entitlementRemoved { + t.Fatal("start fresh removed entitlement") + } +} + +func TestStartFreshHandler_DoesNotDeleteProviderCustomer(t *testing.T) { + resetAccountDeletionTestHooks(t) + + getUserFromClaimsForAccountDeletion = func(_ *Server, _ echo.Context, _ string) (queries.User, error) { + return accountDeletionTestUser(), nil + } + resetUserPrivateDataForStartFresh = func(context.Context, *Server, pgtype.UUID) error { + return nil + } + deletePolarCustomerForAccountDeletion = func(context.Context, *clients.PolarClient, string, bool) error { + t.Fatal("start fresh must not delete provider customer") + return nil + } + + rec := runStartFreshRequest(t, &Server{Polar: &clients.PolarClient{}}) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } +} + +func TestStartFreshHandler_DoesNotUseAccountDeletionPurge(t *testing.T) { + resetAccountDeletionTestHooks(t) + + var reset bool + getUserFromClaimsForAccountDeletion = func(_ *Server, _ echo.Context, _ string) (queries.User, error) { + return accountDeletionTestUser(), nil + } + purgeUserLocalForAccountDeletion = func(context.Context, *Server, pgtype.UUID) error { + t.Fatal("start fresh must not use account deletion purge") + return nil + } + resetUserPrivateDataForStartFresh = func(context.Context, *Server, pgtype.UUID) error { + reset = true + return nil + } + + rec := runStartFreshRequest(t, &Server{}) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + if !reset { + t.Fatal("private data reset did not run") + } +} + +func TestResetUserPrivateDataForStartFreshWithQueries_ClearsPrivateData(t *testing.T) { + recorder := &recordingStartFreshResetQueries{} + + if err := resetUserPrivateDataForStartFreshWithQueries(context.Background(), recorder, accountDeletionTestUser().ID); err != nil { + t.Fatalf("reset helper returned error: %v", err) + } + + want := []string{ + "DeleteDebtEventSplitsForStartFresh", + "DeleteDebtEventsForStartFresh", + "DeleteDebtsForStartFresh", + "DeleteTransactionsForStartFresh", + "DeleteUserCategoriesForStartFresh", + "DeleteBalancesForStartFresh", + "DeleteUsageCountersForStartFresh", + "PurgeParseAttemptsResults", + "ClearUserEncryptionStateForStartFresh", + } + if got, expected := strings.Join(recorder.calls, ","), strings.Join(want, ","); got != expected { + t.Fatalf("reset calls = %s, want %s", got, expected) + } +} + +type recordingStartFreshResetQueries struct { + calls []string +} + +func (r *recordingStartFreshResetQueries) record(name string) error { + r.calls = append(r.calls, name) + return nil +} + +func (r *recordingStartFreshResetQueries) DeleteDebtEventSplitsForStartFresh(context.Context, pgtype.UUID) error { + return r.record("DeleteDebtEventSplitsForStartFresh") +} + +func (r *recordingStartFreshResetQueries) DeleteDebtEventsForStartFresh(context.Context, pgtype.UUID) error { + return r.record("DeleteDebtEventsForStartFresh") +} + +func (r *recordingStartFreshResetQueries) DeleteDebtsForStartFresh(context.Context, pgtype.UUID) error { + return r.record("DeleteDebtsForStartFresh") +} + +func (r *recordingStartFreshResetQueries) DeleteTransactionsForStartFresh(context.Context, pgtype.UUID) error { + return r.record("DeleteTransactionsForStartFresh") +} + +func (r *recordingStartFreshResetQueries) DeleteUserCategoriesForStartFresh(context.Context, pgtype.UUID) error { + return r.record("DeleteUserCategoriesForStartFresh") +} + +func (r *recordingStartFreshResetQueries) DeleteBalancesForStartFresh(context.Context, pgtype.UUID) error { + return r.record("DeleteBalancesForStartFresh") +} + +func (r *recordingStartFreshResetQueries) DeleteUsageCountersForStartFresh(context.Context, pgtype.UUID) error { + return r.record("DeleteUsageCountersForStartFresh") +} + +func (r *recordingStartFreshResetQueries) PurgeParseAttemptsResults(context.Context, pgtype.UUID) error { + return r.record("PurgeParseAttemptsResults") +} + +func (r *recordingStartFreshResetQueries) ClearUserEncryptionStateForStartFresh(context.Context, pgtype.UUID) error { + return r.record("ClearUserEncryptionStateForStartFresh") +} + +func runStartFreshRequest(t *testing.T, srv *Server) *httptest.ResponseRecorder { + t.Helper() + + e := echo.New() + req := httptest.NewRequest(http.MethodPost, "/api/v1/user/start-fresh", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + if err := srv.StartFreshHandler(c); err != nil { + t.Fatalf("handler returned error: %v", err) + } + return rec +} + +func resetAccountDeletionTestHooks(t *testing.T) { + t.Helper() + + originalGetUser := getUserFromClaimsForAccountDeletion + originalValidate := validateGoogleIDTokenForAccountDeletion + originalNow := nowForAccountDeletion + originalHasPolar := userHasPolarBillingRecordsForAccountDeletion + originalDeletePolar := deletePolarCustomerForAccountDeletion + originalPurge := purgeUserLocalForAccountDeletion + originalReset := resetUserPrivateDataForStartFresh + + t.Cleanup(func() { + getUserFromClaimsForAccountDeletion = originalGetUser + validateGoogleIDTokenForAccountDeletion = originalValidate + nowForAccountDeletion = originalNow + userHasPolarBillingRecordsForAccountDeletion = originalHasPolar + deletePolarCustomerForAccountDeletion = originalDeletePolar + purgeUserLocalForAccountDeletion = originalPurge + resetUserPrivateDataForStartFresh = originalReset + }) +} + +func runAccountDeletionRequest(t *testing.T, srv *Server, body string) *httptest.ResponseRecorder { + t.Helper() + + e := echo.New() + req := httptest.NewRequest(http.MethodDelete, "/api/v1/user/account", strings.NewReader(body)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + if err := srv.RequestAccountDeletionHandler(c); err != nil { + t.Fatalf("handler returned error: %v", err) + } + return rec +} + +func accountDeletionTestUser() queries.User { + sub := "google-sub" + id := uuid.MustParse("00000000-0000-0000-0000-000000000123") + return queries.User{ + ID: pgtype.UUID{Bytes: id, Valid: true}, + Sub: &sub, + } +} + +func accountDeletionValidIDToken(context.Context, string, string) (*idtoken.Payload, error) { + return &idtoken.Payload{ + Claims: map[string]any{ + "sub": "google-sub", + "iat": float64(time.Now().Unix()), + }, + }, nil +} diff --git a/internal/handlers/admin_auth.go b/internal/handlers/admin_auth.go index 57bfb3a..4b58365 100644 --- a/internal/handlers/admin_auth.go +++ b/internal/handlers/admin_auth.go @@ -115,10 +115,8 @@ func (S *Server) adminCredentials() (string, string) { if S.ConfigCache != nil { adminEmail := strings.Trim(S.ConfigCache.GetString("admin_email", ""), `"`) adminHashRaw := strings.Trim(S.ConfigCache.GetString("admin_password_hash", ""), `"`) - if adminEmail != "" && adminHashRaw != "" { - return adminEmail, adminHashRaw - } + return adminEmail, adminHashRaw } - return strings.TrimSpace(config.EnVar.AdminEmail), strings.TrimSpace(config.EnVar.AdminPasswordHash) + return strings.TrimSpace(config.EnVar.AdminEmail), "" } diff --git a/internal/handlers/admin_dashboard.go b/internal/handlers/admin_dashboard.go index a6eefb7..90da3d2 100644 --- a/internal/handlers/admin_dashboard.go +++ b/internal/handlers/admin_dashboard.go @@ -66,12 +66,6 @@ func (S *Server) AdminDashboardHandler(c echo.Context) error { failRate = float64(parseStats.Failed) / float64(parseStats.Total) } - billingStats, err := S.Queries.AdminDashboardBillingJobStats(ctx) - if err != nil { - S.LogErr(c, op, err) - return c.JSON(http.StatusInternalServerError, errResponse(msg.ErrInternalServerError, "")) - } - return c.JSON(http.StatusOK, map[string]any{ "users": map[string]any{ "total": userStats.Total, @@ -91,9 +85,5 @@ func (S *Server) AdminDashboardHandler(c echo.Context) error { "parse_success_rate_today": successRate, "parse_fail_rate_today": failRate, }, - "billing_jobs": map[string]any{ - "pending": billingStats.Pending, - "failed": billingStats.Failed, - }, }) } diff --git a/internal/handlers/admin_store_products.go b/internal/handlers/admin_store_products.go index 8ab00c3..29fe18e 100644 --- a/internal/handlers/admin_store_products.go +++ b/internal/handlers/admin_store_products.go @@ -58,7 +58,6 @@ func (S *Server) AdminSyncPolarStoreProductsHandler(c echo.Context) error { S.LogErr(c, op, err) return c.JSON(http.StatusBadGateway, msgResponse(msg.ErrFailedToLoadPlans)) } - return c.JSON(http.StatusOK, map[string]any{ "provider": "polar", "created": summary.Created, @@ -108,14 +107,18 @@ func SyncPolarStoreProducts(ctx context.Context, source polarStoreProductSyncSou priceMinor := product.PriceMinor currencyCode := product.CurrencyCode + trialDays := trialIntervalToDays(product.TrialInterval, product.TrialIntervalCount) _, err := q.UpsertStoreProductByProviderAndProductID(ctx, queries.UpsertStoreProductByProviderAndProductIDParams{ - PlanID: product.PlanID, - Provider: "polar", - StoreProductID: product.ProductID, - Period: product.BillingPeriod, - PriceMinor: &priceMinor, - CurrencyCode: ¤cyCode, - IsActive: true, + PlanID: product.PlanID, + Provider: "polar", + StoreProductID: product.ProductID, + Period: product.BillingPeriod, + PriceMinor: &priceMinor, + CurrencyCode: ¤cyCode, + TrialDays: trialDays, + TrialInterval: product.TrialInterval, + TrialIntervalCount: product.TrialIntervalCount, + IsActive: true, }) if err != nil { return summary, fmt.Errorf("upsert polar product %s: %w", product.ProductID, err) @@ -140,6 +143,24 @@ func SyncPolarStoreProducts(ctx context.Context, source polarStoreProductSyncSou return summary, nil } +func trialIntervalToDays(interval string, count int32) int32 { + if count <= 0 { + return 0 + } + switch interval { + case "day": + return count + case "week": + return count * 7 + case "month": + return count * 30 + case "year": + return count * 365 + default: + return 0 + } +} + func syncPolarStoreProducts(ctx context.Context, source polarStoreProductSyncSource, q polarStoreProductSyncQueries) (PolarStoreProductSyncSummary, error) { return SyncPolarStoreProducts(ctx, source, q) } diff --git a/internal/handlers/admin_store_products_test.go b/internal/handlers/admin_store_products_test.go index 3494003..8e8cbe0 100644 --- a/internal/handlers/admin_store_products_test.go +++ b/internal/handlers/admin_store_products_test.go @@ -13,6 +13,7 @@ import ( "github.com/jackc/pgx/v5/pgtype" "github.com/labstack/echo/v4" + polargo "github.com/polarsource/polar-go" "github.com/polarsource/polar-go/models/components" ) @@ -102,6 +103,9 @@ func TestSyncPolarStoreProducts_CreatesUpdatesDeactivatesAndSkips(t *testing.T) if got, want := q.deactivated[0].StoreProductIds, []string{"polar_monthly", "polar_yearly"}; !sliceEqual(got, want) { t.Fatalf("deactivation keep list = %#v, want %#v", got, want) } + if q.upserts[0].TrialInterval != "week" || q.upserts[0].TrialIntervalCount != 1 || q.upserts[0].TrialDays != 7 { + t.Fatalf("trial fields = %q/%d/%d, want week/1/7", q.upserts[0].TrialInterval, q.upserts[0].TrialIntervalCount, q.upserts[0].TrialDays) + } } func TestAdminSyncPolarStoreProductsHandler_ReturnsSummary(t *testing.T) { @@ -167,11 +171,13 @@ func mockSyncPolarProduct(id, name, plan, period string, priceAmount int64, curr } return components.Product{ - ID: id, - Name: name, - IsArchived: archived, - IsRecurring: recurring, - Metadata: meta, + ID: id, + Name: name, + IsArchived: archived, + IsRecurring: recurring, + Metadata: meta, + TrialInterval: components.TrialIntervalWeek.ToPointer(), + TrialIntervalCount: polargo.Int64(1), Prices: []components.Prices{ { ProductPrice: &components.ProductPrice{ diff --git a/internal/handlers/admin_subscriptions.go b/internal/handlers/admin_subscriptions.go index bd31a95..093fd7c 100644 --- a/internal/handlers/admin_subscriptions.go +++ b/internal/handlers/admin_subscriptions.go @@ -5,8 +5,6 @@ import ( "numex-api/internal/db/queries" "numex-api/internal/msg" - "github.com/google/uuid" - "github.com/jackc/pgx/v5/pgtype" "github.com/labstack/echo/v4" ) @@ -33,41 +31,6 @@ func (S *Server) AdminListSubscriptionsHandler(c echo.Context) error { return c.JSON(http.StatusOK, subs) } -func (S *Server) AdminListBillingJobsHandler(c echo.Context) error { - const op = "AdminListBillingJobs" - ctx := c.Request().Context() - status := c.QueryParam("status") - limit, offset := adminPagination(c) - - jobs, err := S.Queries.ListBillingJobsAdmin(ctx, queries.ListBillingJobsAdminParams{ - Column1: status, - Limit: limit, - Offset: offset, - }) - if err != nil { - S.LogErr(c, op, err) - return c.JSON(http.StatusInternalServerError, errResponse(msg.ErrInternalServerError, "")) - } - if jobs == nil { - jobs = []queries.ListBillingJobsAdminRow{} - } - return c.JSON(http.StatusOK, jobs) -} - -func (S *Server) AdminRetryBillingJobHandler(c echo.Context) error { - const op = "AdminRetryBillingJob" - rawID, err := uuid.Parse(c.Param("id")) - if err != nil { - return c.JSON(http.StatusBadRequest, errResponse(msg.ErrInvalidReqPayload, "")) - } - job, err := S.Queries.RetryBillingJob(c.Request().Context(), pgtype.UUID{Bytes: rawID, Valid: true}) - if err != nil { - S.LogErr(c, op, err) - return c.JSON(http.StatusInternalServerError, errResponse(msg.ErrInternalServerError, "")) - } - return c.JSON(http.StatusOK, job) -} - func (S *Server) AdminListPurchasesHandler(c echo.Context) error { const op = "AdminListPurchases" limit, offset := adminPagination(c) diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go index fb656a3..c97dc5e 100644 --- a/internal/handlers/auth.go +++ b/internal/handlers/auth.go @@ -1,6 +1,7 @@ package handlers import ( + "context" "encoding/json" "errors" "net/http" @@ -251,6 +252,11 @@ func (S *Server) AuthMeHandler(c echo.Context) error { return c.JSON(http.StatusInternalServerError, map[string]string{"message": msg.ErrInternalServerError}) } + resp := S.buildMeResponse(ctx, user) + return c.JSON(http.StatusOK, resp) +} + +func (S *Server) buildMeResponse(ctx context.Context, user queries.User) meResponse { resp := meResponse{User: user, Entitlement: meEntitlementResponse{Tier: "free", BillingPeriod: string(utils.BillingPeriodMonthly)}} features := map[string]any{} @@ -298,8 +304,7 @@ func (S *Server) AuthMeHandler(c echo.Context) error { resp.FeatureAccess = buildFeatureAccess(features, resp.Entitlement.Tier) resp.QuotaStatus = buildQuotaStatus(ctx, S.Queries, user, features) resp.DowngradeNotice = buildDowngradeNotice(user, resp.Entitlement.Tier, resp.Subscription) - - return c.JSON(http.StatusOK, resp) + return resp } func (S *Server) UpdateMeHandler(c echo.Context) error { @@ -387,7 +392,7 @@ func (S *Server) UpdateMeHandler(c echo.Context) error { } } - return c.JSON(http.StatusOK, user) + return c.JSON(http.StatusOK, S.buildMeResponse(ctx, user)) } func (S *Server) AuthRefreshHandler(c echo.Context) error { diff --git a/internal/handlers/balance.go b/internal/handlers/balance.go index 9433689..7411cae 100644 --- a/internal/handlers/balance.go +++ b/internal/handlers/balance.go @@ -44,7 +44,7 @@ func (S *Server) GetBalancesHandler(c echo.Context) error { result := make([]map[string]any, 0, len(balances)) for _, b := range balances { - result = append(result, balanceRowToMap(b.ID, b.UserID, b.Name, b.Description, b.Currency, b.InitialAmountMinor, b.ColorToken, b.IsSystem, b.SortOrder, b.CreatedAt, b.DisplayName, nil, b.IsArchived, b.ArchiveReason)) + result = append(result, balanceRowToMap(b.ID, b.UserID, b.Name, b.Description, b.Currency, b.InitialAmountMinor, b.ColorToken, b.IsSystem, b.SortOrder, b.CreatedAt, b.DisplayName, b.IsArchived, b.ArchiveReason)) } archived := S.getArchivedBalances(ctx, user.ID, lang) return c.JSON(http.StatusOK, map[string]any{"balances": result, "archived_balances": archived}) @@ -61,14 +61,14 @@ func (S *Server) GetBalancesHandler(c echo.Context) error { result := make([]map[string]any, 0, len(balances)) for _, b := range balances { - result = append(result, balanceRowToMap(b.ID, b.UserID, b.Name, b.Description, b.Currency, b.InitialAmountMinor, b.ColorToken, b.IsSystem, b.SortOrder, b.CreatedAt, b.DisplayName, nil, b.IsArchived, b.ArchiveReason)) + result = append(result, balanceRowToMap(b.ID, b.UserID, b.Name, b.Description, b.Currency, b.InitialAmountMinor, b.ColorToken, b.IsSystem, b.SortOrder, b.CreatedAt, b.DisplayName, b.IsArchived, b.ArchiveReason)) } archived := S.getArchivedBalances(ctx, user.ID, lang) return c.JSON(http.StatusOK, map[string]any{"balances": result, "archived_balances": archived}) } -func balanceRowToMap(id, userID pgtype.UUID, name string, description *string, currency string, initialAmount int64, colorToken string, isSystem bool, sortOrder int32, createdAt pgtype.Timestamptz, displayName string, currentAmount any, isArchived bool, archiveReason *string) map[string]any { +func balanceRowToMap(id, userID pgtype.UUID, name string, description *string, currency string, initialAmount int64, colorToken string, isSystem bool, sortOrder int32, createdAt pgtype.Timestamptz, displayName string, isArchived bool, archiveReason *string) map[string]any { return map[string]any{ "id": id, "user_id": userID, @@ -76,7 +76,6 @@ func balanceRowToMap(id, userID pgtype.UUID, name string, description *string, c "description": description, "currency": currency, "initial_amount_minor": initialAmount, - "current_amount_minor": currentAmount, "color_token": colorToken, "is_system": isSystem, "sort_order": sortOrder, @@ -100,7 +99,7 @@ func (S *Server) getArchivedBalances(ctx context.Context, userID pgtype.UUID, la for _, b := range archivedRows { result = append(result, balanceRowToMap( b.ID, b.UserID, b.Name, b.Description, b.Currency, b.InitialAmountMinor, - b.ColorToken, b.IsSystem, b.SortOrder, b.CreatedAt, b.DisplayName, nil, b.IsArchived, b.ArchiveReason, + b.ColorToken, b.IsSystem, b.SortOrder, b.CreatedAt, b.DisplayName, b.IsArchived, b.ArchiveReason, )) } return result @@ -142,7 +141,7 @@ func (S *Server) GetBalanceHandler(c echo.Context) error { return c.JSON(http.StatusOK, balanceRowToMap( balance.ID, balance.UserID, balance.Name, balance.Description, balance.Currency, balance.InitialAmountMinor, balance.ColorToken, - balance.IsSystem, balance.SortOrder, balance.CreatedAt, balance.DisplayName, nil, balance.IsArchived, balance.ArchiveReason, + balance.IsSystem, balance.SortOrder, balance.CreatedAt, balance.DisplayName, balance.IsArchived, balance.ArchiveReason, )) } @@ -181,15 +180,43 @@ func (S *Server) CreateBalanceHandler(c echo.Context) error { return c.JSON(http.StatusBadRequest, errResponse(msg.ErrCurrencyNotFound, msg.CodeCurrencyNotFound)) } + const privateBalanceNamePlaceholder = "Balance" + + isPrivateCreate := req.EncryptedInitialAmount != "" || req.EncryptedName != "" + name := req.Name + description := utils.StringToPointer(req.Description) + initialAmountMinor := req.InitialAmountMinor + + var encryptedName *string + var encryptedDescription *string + var encryptedInitialAmount *string + + if isPrivateCreate { + if req.EncryptedInitialAmount == "" || req.EncryptedName == "" { + return c.JSON(http.StatusBadRequest, map[string]string{"message": msg.ErrInvalidReqPayload}) + } + name = privateBalanceNamePlaceholder + description = nil + initialAmountMinor = 0 + encryptedName = &req.EncryptedName + if req.EncryptedDescription != "" { + encryptedDescription = &req.EncryptedDescription + } + encryptedInitialAmount = &req.EncryptedInitialAmount + } + balance, err := S.Queries.CreateBalance(ctx, queries.CreateBalanceParams{ - UserID: user.ID, - Name: req.Name, - Description: utils.StringToPointer(req.Description), - Currency: req.Currency, - InitialAmountMinor: req.InitialAmountMinor, - ColorToken: req.ColorToken, - IsSystem: false, - SortOrder: int32(req.SortOrder), // #nosec G115 -- sort order bounded by UI validation + UserID: user.ID, + Name: name, + Description: description, + Currency: req.Currency, + InitialAmountMinor: initialAmountMinor, + ColorToken: req.ColorToken, + IsSystem: false, + SortOrder: int32(req.SortOrder), // #nosec G115 -- sort order bounded by UI validation + EncryptedName: encryptedName, + EncryptedDescription: encryptedDescription, + EncryptedInitialAmount: encryptedInitialAmount, }) if err != nil { if isDuplicateKeyError(err) { diff --git a/internal/handlers/checkout.go b/internal/handlers/checkout.go index 61c7eb8..0f3c196 100644 --- a/internal/handlers/checkout.go +++ b/internal/handlers/checkout.go @@ -2,7 +2,6 @@ package handlers import ( "context" - "log/slog" "net/http" "numex-api/internal/clients" "numex-api/internal/config" @@ -35,8 +34,8 @@ var getStoreProductByIDForCheckout = func(ctx context.Context, q checkoutProduct return q.GetStoreProductByID(ctx, id) } -var createPolarCheckoutForCheckout = func(ctx context.Context, polar *clients.PolarClient, productID, successURL, externalCustomerID string) (string, error) { - return polar.CreateCheckout(ctx, productID, successURL, externalCustomerID) +var createPolarCheckoutForCheckout = func(ctx context.Context, polar *clients.PolarClient, productID, successURL, externalCustomerID, customerEmail string) (string, error) { + return polar.CreateCheckout(ctx, productID, successURL, externalCustomerID, customerEmail) } // CreatePolarCheckoutHandler creates a Polar hosted checkout session. @@ -73,29 +72,13 @@ func (S *Server) CreatePolarCheckoutHandler(c echo.Context) error { } successURL := S.polarSuccessURL() - slog.Info("TEMP DEBUG polar checkout request", - "user_id", user.ID.String(), - "product_id", req.ProductID, - "store_product_id", product.StoreProductID, - "period", product.Period, - "success_url", successURL, - ) - slog.Info("TEMP DEBUG polar checkout correlation configured", - "external_customer_id", user.ID.String(), - ) - - checkoutURL, err := createPolarCheckoutForCheckout(ctx, S.Polar, product.StoreProductID, successURL, user.ID.String()) + + checkoutURL, err := createPolarCheckoutForCheckout(ctx, S.Polar, product.StoreProductID, successURL, user.ID.String(), user.Email) if err != nil { S.LogErr(c, op, err) return c.JSON(http.StatusBadGateway, msgResponse(msg.ErrCheckoutCreationFailed)) } - slog.Info("TEMP DEBUG polar checkout created", - "product_id", req.ProductID, - "store_product_id", product.StoreProductID, - "checkout_url", checkoutURL, - ) - return c.JSON(http.StatusOK, map[string]string{ "checkout_url": checkoutURL, }) diff --git a/internal/handlers/checkout_test.go b/internal/handlers/checkout_test.go index 62c98e4..f64283c 100644 --- a/internal/handlers/checkout_test.go +++ b/internal/handlers/checkout_test.go @@ -33,6 +33,7 @@ func TestCreatePolarCheckoutHandler_PassesExternalCustomerIDAndProductRouting(t var gotProductID string var gotSuccessURL string var gotExternalCustomerID string + var gotCustomerEmail string isPolarEnabledForCheckout = func(_ *Server) bool { return true } getUserFromClaimsForCheckout = func(_ *Server, _ echo.Context, _ string) (queries.User, error) { @@ -40,7 +41,7 @@ func TestCreatePolarCheckoutHandler_PassesExternalCustomerIDAndProductRouting(t u := uuid.MustParse("00000000-0000-0000-0000-000000000456") copy(id.Bytes[:], u[:]) id.Valid = true - return queries.User{ID: id}, nil + return queries.User{ID: id, Email: " buyer@example.com "}, nil } getStoreProductByIDForCheckout = func(_ context.Context, _ checkoutProductLookup, _ pgtype.UUID) (queries.StoreProduct, error) { var id pgtype.UUID @@ -56,10 +57,11 @@ func TestCreatePolarCheckoutHandler_PassesExternalCustomerIDAndProductRouting(t IsActive: true, }, nil } - createPolarCheckoutForCheckout = func(_ context.Context, _ *clients.PolarClient, productID, successURL, externalCustomerID string) (string, error) { + createPolarCheckoutForCheckout = func(_ context.Context, _ *clients.PolarClient, productID, successURL, externalCustomerID, customerEmail string) (string, error) { gotProductID = productID gotSuccessURL = successURL gotExternalCustomerID = externalCustomerID + gotCustomerEmail = customerEmail return "https://checkout.example/polar-session", nil } @@ -86,6 +88,9 @@ func TestCreatePolarCheckoutHandler_PassesExternalCustomerIDAndProductRouting(t if gotExternalCustomerID != "00000000-0000-0000-0000-000000000456" { t.Fatalf("externalCustomerID = %q, want user UUID", gotExternalCustomerID) } + if gotCustomerEmail != " buyer@example.com " { + t.Fatalf("customerEmail = %q, want raw user email passed to client", gotCustomerEmail) + } var body map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { diff --git a/internal/handlers/debt.go b/internal/handlers/debt.go index 4458b92..8735f11 100644 --- a/internal/handlers/debt.go +++ b/internal/handlers/debt.go @@ -7,6 +7,7 @@ import ( "numex-api/internal/db/queries" "numex-api/internal/models" "numex-api/internal/msg" + "numex-api/internal/services" "numex-api/internal/utils" "time" @@ -95,7 +96,16 @@ func (S *Server) GetDebtHandler(c echo.Context) error { return c.JSON(http.StatusNotFound, errResponse(msg.ErrDebtNotFound, "")) } - return c.JSON(http.StatusOK, debtToResponse(debt, nil)) + events, err := S.Queries.GetDebtEventsByDebtID(ctx, queries.GetDebtEventsByDebtIDParams{ + UserID: user.ID, + DebtID: debtUUID, + }) + if err != nil { + S.LogErr(c, op, err) + return c.JSON(http.StatusInternalServerError, errResponse(msg.ErrInternalServerError, "")) + } + + return c.JSON(http.StatusOK, debtToResponse(debt, debtRepaymentsFromEvents(events))) } // CreateDebtHandler creates a debt manually. @@ -278,7 +288,28 @@ func (S *Server) DeleteDebtHandler(c echo.Context) error { // recalcDebtRemaining recalculates a debt's remaining amount from linked // repayment transactions and updates it. Auto-archives when remaining <= 0. func (S *Server) recalcDebtRemaining(ctx context.Context, debtID pgtype.UUID, userID pgtype.UUID) error { - return nil + debt, err := S.Queries.GetDebtByID(ctx, queries.GetDebtByIDParams{ + ID: debtID, + UserID: userID, + }) + if err != nil { + return err + } + events, err := S.Queries.GetDebtEventsByDebtID(ctx, queries.GetDebtEventsByDebtIDParams{ + UserID: userID, + DebtID: debtID, + }) + if err != nil { + return err + } + remaining, overpaid, status := services.RecalculateRemaining(debt.AmountMinorOriginal, events) + return S.Queries.UpdateDebtRemainingAndStatus(ctx, queries.UpdateDebtRemainingAndStatusParams{ + UserID: userID, + ID: debtID, + AmountMinorRemaining: remaining, + OverpaidAmountMinor: overpaid, + Status: status, + }) } func debtToResponse(d queries.Debt, repayments []models.RepaymentEntry) models.DebtResponse { @@ -288,6 +319,7 @@ func debtToResponse(d queries.Debt, repayments []models.RepaymentEntry) models.D Direction: d.Direction, AmountMinorOriginal: d.AmountMinorOriginal, AmountMinorRemaining: d.AmountMinorRemaining, + OverpaidAmountMinor: d.OverpaidAmountMinor, Currency: d.Currency, Status: d.Status, Source: d.Source, @@ -300,6 +332,21 @@ func debtToResponse(d queries.Debt, repayments []models.RepaymentEntry) models.D return r } +func debtRepaymentsFromEvents(events []queries.DebtEvent) []models.RepaymentEntry { + repayments := make([]models.RepaymentEntry, 0) + for _, event := range events { + if event.Kind != "repayment" { + continue + } + repayments = append(repayments, models.RepaymentEntry{ + AmountMinor: event.AmountMinor, + Currency: event.Currency, + OccurredAt: event.CreatedAt.Time.UTC().Format(time.RFC3339), + }) + } + return repayments +} + func uuidToStr(id pgtype.UUID) string { return fmt.Sprintf("%x-%x-%x-%x-%x", id.Bytes[0:4], id.Bytes[4:6], id.Bytes[6:8], id.Bytes[8:10], id.Bytes[10:16]) diff --git a/internal/handlers/debt_bundle.go b/internal/handlers/debt_bundle.go new file mode 100644 index 0000000..d4c3598 --- /dev/null +++ b/internal/handlers/debt_bundle.go @@ -0,0 +1,81 @@ +package handlers + +import ( + "errors" + "net/http" + + "numex-api/internal/models" + "numex-api/internal/msg" + "numex-api/internal/services" + + "github.com/labstack/echo/v4" +) + +func (S *Server) CreateDebtBundleHandler(c echo.Context) error { + const op = "CreateDebtBundle" + user, err := S.getUserFromClaims(c, op) + if err != nil { + return claimsError(c) + } + + var req models.DebtBundleEventRequest + if err := c.Bind(&req); err != nil { + return c.JSON(http.StatusBadRequest, errResponse(msg.ErrInvalidReqPayload, msg.CodeValidationError)) + } + if err := S.Validate.Struct(req); err != nil { + return c.JSON(http.StatusBadRequest, errResponse(msg.ErrInvalidReqPayload, msg.CodeValidationError)) + } + + service := services.NewDebtBundleService(S.DB, S.Queries) + resp, err := service.Create(c.Request().Context(), user, req) + if err != nil { + return debtBundleError(c, S, op, err) + } + return c.JSON(http.StatusCreated, resp) +} + +func (S *Server) UpdateDebtBundleHandler(c echo.Context) error { + const op = "UpdateDebtBundle" + user, err := S.getUserFromClaims(c, op) + if err != nil { + return claimsError(c) + } + + var req models.DebtBundleEventRequest + if err := c.Bind(&req); err != nil { + return c.JSON(http.StatusBadRequest, errResponse(msg.ErrInvalidReqPayload, msg.CodeValidationError)) + } + if err := S.Validate.Struct(req); err != nil { + return c.JSON(http.StatusBadRequest, errResponse(msg.ErrInvalidReqPayload, msg.CodeValidationError)) + } + + resp, err := services.NewDebtBundleService(S.DB, S.Queries).Update(c.Request().Context(), user, c.Param("event_id"), req) + if err != nil { + return debtBundleError(c, S, op, err) + } + return c.JSON(http.StatusOK, resp) +} + +func (S *Server) DeleteDebtBundleHandler(c echo.Context) error { + const op = "DeleteDebtBundle" + user, err := S.getUserFromClaims(c, op) + if err != nil { + return claimsError(c) + } + + if err := services.NewDebtBundleService(S.DB, S.Queries).Delete(c.Request().Context(), user, c.Param("event_id")); err != nil { + return debtBundleError(c, S, op, err) + } + return c.NoContent(http.StatusNoContent) +} + +func debtBundleError(c echo.Context, S *Server, op string, err error) error { + if errors.Is(err, services.ErrDebtBundleInvalid) { + return c.JSON(http.StatusBadRequest, errResponse(msg.ErrInvalidReqPayload, msg.CodeValidationError)) + } + if errors.Is(err, services.ErrDebtBundleEncryptionKeysRequired) { + return c.JSON(http.StatusConflict, msgResponse(msg.ErrEncryptionKeysRequired)) + } + S.LogErr(c, op, err) + return c.JSON(http.StatusInternalServerError, errResponse(msg.ErrInternalServerError, msg.CodeInternalError)) +} diff --git a/internal/handlers/debt_bundle_test.go b/internal/handlers/debt_bundle_test.go new file mode 100644 index 0000000..ffef5d8 --- /dev/null +++ b/internal/handlers/debt_bundle_test.go @@ -0,0 +1,29 @@ +package handlers + +import ( + "net/http" + "net/http/httptest" + "testing" + + "numex-api/internal/services" + + "github.com/labstack/echo/v4" +) + +func TestCreateDebtBundleHandlerRejectsMissingCounterparty(t *testing.T) { + t.Skip("Enable after test server auth helper is wired for debt bundle endpoint") +} + +func TestDebtBundleErrorMapsEncryptionKeysRequired(t *testing.T) { + e := echo.New() + req := httptest.NewRequest(http.MethodPost, "/api/v1/debt-bundles", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + if err := debtBundleError(c, &Server{}, "test", services.ErrDebtBundleEncryptionKeysRequired); err != nil { + t.Fatalf("debtBundleError() error = %v", err) + } + if rec.Code != http.StatusConflict { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusConflict) + } +} diff --git a/internal/handlers/entitlement_state.go b/internal/handlers/entitlement_state.go index 1bd9307..a111a19 100644 --- a/internal/handlers/entitlement_state.go +++ b/internal/handlers/entitlement_state.go @@ -117,15 +117,8 @@ func buildDowngradeNotice(user queries.User, currentTier string, sub *meSubscrip return notice } -func queueDowngradeCleanup(ctx context.Context, q *queries.Queries, userID pgtype.UUID, reason string) { - _, _ = q.CreateDowngradeCleanupJob(ctx, queries.CreateDowngradeCleanupJobParams{ - UserID: userID, - Reason: reason, - RunAt: pgtype.Timestamptz{Time: time.Now(), Valid: true}, - }) -} - func recordDowngrade(ctx context.Context, q *queries.Queries, userID pgtype.UUID, reason string, effects []map[string]any) { + effects = append(effects, cleanupDowngradedUser(ctx, q, userID)...) payload, _ := json.Marshal(effects) var reasonPtr *string if reason != "" { @@ -136,7 +129,56 @@ func recordDowngrade(ctx context.Context, q *queries.Queries, userID pgtype.UUID LastDowngradeReason: reasonPtr, DowngradeEffects: payload, }) - queueDowngradeCleanup(ctx, q, userID, reason) + _, _ = q.PurgeExpiredArchivedBalances(ctx) +} + +func cleanupDowngradedUser(ctx context.Context, q *queries.Queries, userID pgtype.UUID) []map[string]any { + freeFeatures, err := q.GetFreePlanFeatures(ctx) + if err != nil { + return nil + } + + maxActive := 1 + var features map[string]any + if err := json.Unmarshal(freeFeatures, &features); err == nil { + maxActive = getFeatureInt(features, "max_balances", maxActive) + } + + extras, err := q.GetActiveBalancesOverLimitByUserID(ctx, queries.GetActiveBalancesOverLimitByUserIDParams{ + UserID: userID, + Offset: int32(maxActive), + }) + if err != nil { + return nil + } + + archivedCount := 0 + expiresAt := pgtype.Timestamptz{Time: time.Now().Add(30 * 24 * time.Hour), Valid: true} + for _, balance := range extras { + if balance.IsSystem { + continue + } + if err := q.ArchiveBalance(ctx, queries.ArchiveBalanceParams{ + ID: balance.ID, + ArchiveReason: strPtr("downgraded_to_free"), + ArchiveExpiresAt: expiresAt, + }); err != nil { + continue + } + archivedCount++ + } + + if archivedCount == 0 { + return nil + } + return []map[string]any{{ + "kind": "archived_balances", + "count": archivedCount, + }} +} + +func strPtr(s string) *string { + return &s } func getVoiceQuotaLimits(features map[string]any) (submissionsLimit int, transactionsLimit int) { diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index f8463d0..aaa2512 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -3,6 +3,9 @@ package handlers import ( "encoding/json" "net/http" + "time" + + "numex-api/internal/broadcast" "numex-api/internal/cache" "numex-api/internal/clients" "numex-api/internal/db/queries" @@ -14,6 +17,7 @@ import ( "github.com/go-playground/validator/v10" "github.com/jackc/pgx/v5/pgxpool" "github.com/labstack/echo/v4" + "github.com/labstack/echo/v4/middleware" "github.com/redis/go-redis/v9" ) @@ -27,6 +31,7 @@ type Server struct { Payme *clients.PaymeClient Polar *clients.PolarClient Email *clients.EmailService + Broadcaster *broadcast.LogBroadcaster } func (S *Server) LogErr(c echo.Context, op string, err error) { @@ -102,6 +107,23 @@ func Handlers(e *echo.Echo, s *Server) { jwt := middlewares.JWTMiddleware admin := middlewares.AdminMiddleware(s.Queries) + // Per-user admission gates for AI-driven endpoints. + voiceAdmit := middlewares.UserAIAdmission(s.Redis, middlewares.AIAdmissionConfig{ + KeyPrefix: "voice", + Limit: 20, + Window: time.Minute, + }) + textParseAdmit := middlewares.UserAIAdmission(s.Redis, middlewares.AIAdmissionConfig{ + KeyPrefix: "text_parse", + Limit: 30, + Window: time.Minute, + }) + insightAdmit := middlewares.UserAIAdmission(s.Redis, middlewares.AIAdmissionConfig{ + KeyPrefix: "insight", + Limit: 10, + Window: time.Minute, + }) + // Auth e.POST("/api/auth/google", s.GoogleAuthHandler) e.GET("/api/auth/me", s.AuthMeHandler, jwt) @@ -111,17 +133,17 @@ func Handlers(e *echo.Echo, s *Server) { e.DELETE("/api/auth/logout", s.LogoutHandler, jwt) e.DELETE("/api/auth/logout/:id", s.DeleteSessionHandler, jwt) e.GET("/api/auth/sessions", s.GetAllSessionsHandler, jwt) - // Transactions e.GET("/api/transactions", s.GetTransactionsHandler, jwt) e.GET("/api/transactions/:id", s.GetTransactionHandler, jwt) e.POST("/api/transactions", s.CreateTransactionHandler, jwt) e.PATCH("/api/transactions/:id", s.UpdateTransactionHandler, jwt) e.DELETE("/api/transactions/:id", s.DeleteTransactionHandler, jwt) + e.PATCH("/api/transactions/:id/reprocess", s.ReprocessTransactionHandler, jwt, middlewares.NoLogBody(), textParseAdmit) // Transaction parsing (no-log zone: request/response contain financial data) - e.POST("/api/transactions/parse", s.ParseTransactionHandler, jwt, middlewares.NoLogBody()) - e.POST("/api/transactions/voice", s.VoiceTransactionHandler, jwt, middlewares.NoLogBody()) + e.POST("/api/transactions/parse", s.ParseTransactionHandler, jwt, middlewares.NoLogBody(), textParseAdmit) + e.POST("/api/transactions/voice", s.VoiceTransactionHandler, jwt, middlewares.NoLogBody(), middleware.BodyLimit("10M"), voiceAdmit) // Categories e.GET("/api/categories", s.GetCategoriesHandler, jwt) @@ -144,7 +166,7 @@ func Handlers(e *echo.Echo, s *Server) { e.DELETE("/api/balances/:id", s.DeleteBalanceHandler, jwt) // Currencies - e.GET("/api/currencies", s.GetActiveCurrenciesHandler, jwt) + e.GET("/api/currencies", s.GetActiveCurrenciesHandler) e.GET("/api/currencies/rates", s.GetExchangeRatesHandler, jwt) e.GET("/api/currencies/rates/:base", s.GetRatesByCurrencyHandler, jwt) e.GET("/api/currencies/convert", s.ConvertCurrencyHandler, jwt) @@ -158,6 +180,9 @@ func Handlers(e *echo.Echo, s *Server) { // Dashboard e.GET("/api/admin/dashboard", s.AdminDashboardHandler, jwt, admin) + // Admin Logs + e.GET("/api/admin/logs/stream", s.LogsStreamHandler, jwt, admin) + // User management e.GET("/api/admin/users", s.AdminListUsersHandler, jwt, admin) e.GET("/api/admin/users/banned", s.AdminListBannedEmailsHandler, jwt, admin) @@ -185,8 +210,6 @@ func Handlers(e *echo.Echo, s *Server) { // Subscriptions + Billing e.GET("/api/admin/subscriptions", s.AdminListSubscriptionsHandler, jwt, admin) - e.GET("/api/admin/billing-jobs", s.AdminListBillingJobsHandler, jwt, admin) - e.POST("/api/admin/billing-jobs/:id/retry", s.AdminRetryBillingJobHandler, jwt, admin) e.GET("/api/admin/purchases", s.AdminListPurchasesHandler, jwt, admin) e.PUT("/api/admin/currencies/rates", s.AdminUpsertRateHandler, jwt, admin) @@ -229,6 +252,9 @@ func Handlers(e *echo.Echo, s *Server) { // Public app config (no auth — feature flags for unauthenticated Flutter clients) e.GET("/api/v1/config", s.GetPublicConfigHandler) + // Onboarding quiz answers + e.PATCH("/api/v1/user/onboarding-context", s.PatchUserOnboardingContextHandler, jwt) + // Encryption key management (upload-first: server before Keychain) e.POST("/api/v1/user/keys", s.StoreUserKeysHandler, jwt) e.GET("/api/v1/user/keys", s.GetUserKeysHandler, jwt) @@ -240,10 +266,11 @@ func Handlers(e *echo.Echo, s *Server) { e.GET("/api/v1/reference-data", s.GetReferenceDataHandler, jwt) // AI Insights proxy (no-log zone: user financial data in request body) - e.POST("/api/v1/user/insights/generate", s.ProxyInsightHandler, jwt, middlewares.NoLogBody()) + e.POST("/api/v1/user/insights/generate", s.ProxyInsightHandler, jwt, middlewares.NoLogBody(), insightAdmit) // Account deletion e.DELETE("/api/v1/user/account", s.RequestAccountDeletionHandler, jwt) + e.POST("/api/v1/user/start-fresh", s.StartFreshHandler, jwt) // Supported Languages (public) e.GET("/api/languages", s.GetSupportedLanguagesHandler) @@ -262,6 +289,9 @@ func Handlers(e *echo.Echo, s *Server) { e.PUT("/api/v1/debts/:id", s.UpdateDebtHandler, jwt) e.POST("/api/v1/debts/:id/settle", s.SettleDebtHandler, jwt) e.DELETE("/api/v1/debts/:id", s.DeleteDebtHandler, jwt) + e.POST("/api/v1/debt-bundles", s.CreateDebtBundleHandler, jwt, middlewares.NoLogBody()) + e.PUT("/api/v1/debt-bundles/:event_id", s.UpdateDebtBundleHandler, jwt, middlewares.NoLogBody()) + e.DELETE("/api/v1/debt-bundles/:event_id", s.DeleteDebtBundleHandler, jwt) // Subscriptions (authenticated) e.GET("/api/v1/subscriptions/current", s.GetSubscriptionHandler, jwt) diff --git a/internal/handlers/insights.go b/internal/handlers/insights.go index 0e69e6c..c9953f2 100644 --- a/internal/handlers/insights.go +++ b/internal/handlers/insights.go @@ -3,13 +3,13 @@ package handlers import ( "fmt" "net/http" + "numex-api/internal/services" "strings" "time" "github.com/labstack/echo/v4" "numex-api/internal/msg" - "numex-api/internal/utils" ) type GenerateInsightRequest struct { @@ -47,17 +47,15 @@ func (S *Server) ProxyInsightHandler(c echo.Context) error { return c.JSON(http.StatusForbidden, errResponse(msg.ErrPremiumRequired, msg.CodePremiumRequired)) } - // Rate limit: 10 req/hr/user via Redis + // Rate limit: 10 req/hr/user via Redis-backed admission service rateLimitKey := fmt.Sprintf("insight_rate:%s", user.ID.String()) - count, err := S.Redis.Incr(ctx, rateLimitKey).Result() + admission := services.NewAdmissionService(services.NewRedisCounterStore(S.Redis)) + allowed, _, err := admission.AllowUserWindow(ctx, rateLimitKey, 10, time.Hour) if err != nil { S.LogErr(c, op, err) return c.JSON(http.StatusInternalServerError, errResponse(msg.ErrInternalServerError, "-1")) } - if count == 1 { - S.Redis.Expire(ctx, rateLimitKey, time.Hour) - } - if count > 10 { + if !allowed { return c.JSON(http.StatusTooManyRequests, errResponse(msg.ErrInsightRateLimited, msg.CodeInsightRateLimited)) } @@ -74,45 +72,12 @@ func (S *Server) ProxyInsightHandler(c echo.Context) error { if len(req.Lang) != 2 || !isAlpha(req.Lang) { return c.JSON(http.StatusBadRequest, errResponse(msg.ErrInvalidReqPayload, "-1")) } - - // Create Gemini client and fetch prompt - client, err := S.Gemini.CreateClient(ctx) - if err != nil { - S.LogErr(c, op, err) - return c.JSON(http.StatusInternalServerError, errResponse(msg.ErrInternalServerError, "-1")) - } - - promptDb, err := S.Queries.GetActivePromptByName(ctx, "insight_generate") - if err != nil { - S.LogErr(c, op, err) - return c.JSON(http.StatusInternalServerError, errResponse(msg.ErrInternalServerError, "-1")) - } - - cfg := client.BuildGeminiConfig(promptDb, "application/json") - - // User input in user role — never in system instruction (prevents prompt injection) - userContent := fmt.Sprintf("lang: %s\n\n%s", req.Lang, req.ToonPayload) - - if user.ContextSummary != nil && *user.ContextSummary != "" { - userContent += "\n\nUSER_CONTEXT:\n" + *user.ContextSummary - } - - resp, err := client.Generate(ctx, userContent, cfg, promptDb.Model) - if err != nil { - S.LogErr(c, op, err) - return c.JSON(http.StatusInternalServerError, errResponse(msg.ErrInternalServerError, "-1")) - } - - title, body, err := utils.ParseInsightJSON(resp.Text()) + statusCode, body, err := S.processInsight(ctx, user, req) if err != nil { S.LogErr(c, op, err) - return c.JSON(http.StatusInternalServerError, errResponse(msg.ErrInternalServerError, "-1")) + return c.JSON(http.StatusInternalServerError, errResponse(msg.ErrInternalServerError, msg.CodeInternalError)) } - - return c.JSON(http.StatusOK, GenerateInsightResponse{ - Title: title, - Body: body, - }) + return c.JSONBlob(statusCode, body) } func isAlpha(s string) bool { diff --git a/internal/handlers/insights_process.go b/internal/handlers/insights_process.go new file mode 100644 index 0000000..8a7c8ba --- /dev/null +++ b/internal/handlers/insights_process.go @@ -0,0 +1,104 @@ +package handlers + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/http" + + "numex-api/internal/clients" + "numex-api/internal/db/queries" + "numex-api/internal/msg" + "numex-api/internal/utils" + + "google.golang.org/genai" +) + +var ( + createGeminiClientForInsight = func(ctx context.Context, s *Server) (*clients.GeminiClient, error) { + return s.Gemini.CreateClient(ctx) + } + getActivePromptForInsight = func(ctx context.Context, q insightPromptLookup) (queries.AiPrompt, error) { + return q.GetActivePromptByName(ctx, "insight_generate") + } + generateContentForInsight = func(ctx context.Context, client *clients.GeminiClient, prompt string, cfg *genai.GenerateContentConfig, model string) (string, error) { + resp, err := client.Generate(ctx, prompt, cfg, model) + if err != nil { + return "", err + } + return resp.Text(), nil + } +) + +type insightPromptLookup interface { + GetActivePromptByName(context.Context, string) (queries.AiPrompt, error) +} + +func (S *Server) processInsight(ctx context.Context, user queries.User, req GenerateInsightRequest) (int, []byte, error) { + client, err := createGeminiClientForInsight(ctx, S) + if err != nil { + slog.Warn("insight generation failed", "reason", "gemini_client_unavailable", "user_id", user.ID.String()) + return http.StatusInternalServerError, nil, err + } + + promptDb, err := getActivePromptForInsight(ctx, S.Queries) + if err != nil { + slog.Warn("insight generation failed", "reason", "insight_prompt_missing", "user_id", user.ID.String()) + return http.StatusInternalServerError, nil, err + } + + cfg := client.BuildGeminiConfig(promptDb, "application/json") + userContent := fmt.Sprintf("lang: %s\n\n%s", req.Lang, req.ToonPayload) + if user.ContextSummary != nil && *user.ContextSummary != "" { + userContent += "\n\nUSER_CONTEXT:\n" + *user.ContextSummary + } + + text, err := generateContentForInsight(ctx, client, userContent, cfg, promptDb.Model) + if err != nil { + slog.Warn("insight generation failed", "reason", "gemini_generate_error", "user_id", user.ID.String(), "model", promptDb.Model) + if errors.Is(err, clients.ErrGeminiTemporarilyUnavailable) { + body, _ := json.Marshal(errResponse(msg.ErrSystemBusy, msg.CodeSystemBusy)) + return http.StatusServiceUnavailable, body, nil + } + body, _ := json.Marshal(errResponse(msg.ErrInternalServerError, msg.CodeInternalError)) + return http.StatusInternalServerError, body, nil + } + + title, bodyText, parseErr := utils.ParseInsightJSON(utils.CleanGeminiResponse(text)) + if parseErr != nil { + retryPrompt := userContent + "\n\nReturn compact valid JSON only with non-empty string fields \"title\" and \"body\". Do not use markdown fences." + retryText, retryErr := generateContentForInsight(ctx, client, retryPrompt, cfg, promptDb.Model) + if retryErr == nil { + title, bodyText, parseErr = utils.ParseInsightJSON(utils.CleanGeminiResponse(retryText)) + } + if retryErr != nil || parseErr != nil { + slog.Warn( + "insight generation failed", + "reason", "insight_json_retry_failed", + "user_id", user.ID.String(), + "model", promptDb.Model, + "response_len", len(text), + ) + title, bodyText = fallbackInsightCopy(req.Lang) + } + } + + body, _ := json.Marshal(GenerateInsightResponse{ + Title: title, + Body: bodyText, + }) + return http.StatusOK, body, nil +} + +func fallbackInsightCopy(lang string) (string, string) { + switch lang { + case "ru": + return "Snapshot ready", "Your latest activity is up to date. We could not generate a custom insight this time, but you can try again later." + case "uz": + return "Xulosa tayyor", "Hozircha shaxsiy maslahatni yaratib bo'lmadi. Ma'lumotlaringiz yangilandi, keyinroq yana urinib ko'ring." + default: + return "Snapshot ready", "Your latest activity is up to date. We could not generate a custom insight this time, but you can try again later." + } +} diff --git a/internal/handlers/insights_process_test.go b/internal/handlers/insights_process_test.go new file mode 100644 index 0000000..fec4aed --- /dev/null +++ b/internal/handlers/insights_process_test.go @@ -0,0 +1,186 @@ +package handlers + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "strings" + "testing" + + "numex-api/internal/clients" + "numex-api/internal/db/queries" + + "github.com/jackc/pgx/v5/pgtype" + "google.golang.org/genai" +) + +func TestProcessInsight_RetriesInvalidJSON(t *testing.T) { + originalCreate := createGeminiClientForInsight + originalPrompt := getActivePromptForInsight + originalGenerate := generateContentForInsight + t.Cleanup(func() { + createGeminiClientForInsight = originalCreate + getActivePromptForInsight = originalPrompt + generateContentForInsight = originalGenerate + }) + + createGeminiClientForInsight = func(context.Context, *Server) (*clients.GeminiClient, error) { + return &clients.GeminiClient{}, nil + } + getActivePromptForInsight = func(context.Context, insightPromptLookup) (queries.AiPrompt, error) { + return queries.AiPrompt{Name: "insight_generate", Version: 1, Model: "gemini-2.5-flash"}, nil + } + calls := 0 + generateContentForInsight = func(_ context.Context, _ *clients.GeminiClient, prompt string, _ *genai.GenerateContentConfig, _ string) (string, error) { + calls++ + if strings.Contains(prompt, "total: 100") || strings.Contains(prompt, "USD") { + t.Fatal("prompt leaked financial details to test seam assertion") + } + if calls == 1 { + return "not json", nil + } + if !strings.Contains(prompt, "Return compact valid JSON only") { + t.Fatal("retry prompt missing stricter JSON instruction") + } + return "```json\n{\"title\":\"Good week\",\"body\":\"You stayed close to your plan.\"}\n```", nil + } + + status, body, err := (&Server{}).processInsight(context.Background(), queries.User{ID: pgtype.UUID{Valid: true}}, GenerateInsightRequest{ + ToonPayload: "summary payload", + Lang: "en", + }) + if err != nil { + t.Fatalf("processInsight error = %v", err) + } + if status != http.StatusOK { + t.Fatalf("status = %d, want 200", status) + } + if calls != 2 { + t.Fatalf("generate calls = %d, want 2", calls) + } + var got GenerateInsightResponse + if err := json.Unmarshal(body, &got); err != nil { + t.Fatalf("decode body: %v", err) + } + if got.Title != "Good week" { + t.Fatalf("title = %q, want Good week", got.Title) + } +} + +func TestProcessInsight_Returns500WhenRetryFails(t *testing.T) { + originalCreate := createGeminiClientForInsight + originalPrompt := getActivePromptForInsight + originalGenerate := generateContentForInsight + t.Cleanup(func() { + createGeminiClientForInsight = originalCreate + getActivePromptForInsight = originalPrompt + generateContentForInsight = originalGenerate + }) + + createGeminiClientForInsight = func(context.Context, *Server) (*clients.GeminiClient, error) { + return &clients.GeminiClient{}, nil + } + getActivePromptForInsight = func(context.Context, insightPromptLookup) (queries.AiPrompt, error) { + return queries.AiPrompt{Name: "insight_generate", Version: 1, Model: "gemini-2.5-flash"}, nil + } + generateContentForInsight = func(context.Context, *clients.GeminiClient, string, *genai.GenerateContentConfig, string) (string, error) { + return "", errors.New("provider failed") + } + + status, body, err := (&Server{}).processInsight(context.Background(), queries.User{ID: pgtype.UUID{Valid: true}}, GenerateInsightRequest{ + ToonPayload: "summary payload", + Lang: "en", + }) + if err != nil { + t.Fatalf("processInsight error = %v", err) + } + if status != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500", status) + } + if len(body) == 0 { + t.Fatal("expected error body") + } +} + +func TestProcessInsight_Returns503WhenGeminiPoolExhausted(t *testing.T) { + originalCreate := createGeminiClientForInsight + originalPrompt := getActivePromptForInsight + originalGenerate := generateContentForInsight + t.Cleanup(func() { + createGeminiClientForInsight = originalCreate + getActivePromptForInsight = originalPrompt + generateContentForInsight = originalGenerate + }) + + createGeminiClientForInsight = func(context.Context, *Server) (*clients.GeminiClient, error) { + return &clients.GeminiClient{}, nil + } + getActivePromptForInsight = func(context.Context, insightPromptLookup) (queries.AiPrompt, error) { + return queries.AiPrompt{Name: "insight_generate", Version: 1, Model: "gemini-2.5-flash"}, nil + } + generateContentForInsight = func(context.Context, *clients.GeminiClient, string, *genai.GenerateContentConfig, string) (string, error) { + return "", clients.ErrGeminiTemporarilyUnavailable + } + + status, body, err := (&Server{}).processInsight(context.Background(), queries.User{ID: pgtype.UUID{Valid: true}}, GenerateInsightRequest{ + ToonPayload: "summary payload", + Lang: "en", + }) + if err != nil { + t.Fatalf("processInsight error = %v", err) + } + if status != http.StatusServiceUnavailable { + t.Fatalf("status = %d, want 503", status) + } + if !strings.Contains(string(body), `"code":"SYSTEM_BUSY"`) { + t.Fatalf("body = %s, want SYSTEM_BUSY", body) + } +} + +func TestProcessInsight_ReturnsFallbackWhenRetryParseFails(t *testing.T) { + originalCreate := createGeminiClientForInsight + originalPrompt := getActivePromptForInsight + originalGenerate := generateContentForInsight + t.Cleanup(func() { + createGeminiClientForInsight = originalCreate + getActivePromptForInsight = originalPrompt + generateContentForInsight = originalGenerate + }) + + createGeminiClientForInsight = func(context.Context, *Server) (*clients.GeminiClient, error) { + return &clients.GeminiClient{}, nil + } + getActivePromptForInsight = func(context.Context, insightPromptLookup) (queries.AiPrompt, error) { + return queries.AiPrompt{Name: "insight_generate", Version: 1, Model: "gemini-2.5-flash"}, nil + } + calls := 0 + generateContentForInsight = func(context.Context, *clients.GeminiClient, string, *genai.GenerateContentConfig, string) (string, error) { + calls++ + return `{"title":"","body":""}`, nil + } + + status, body, err := (&Server{}).processInsight(context.Background(), queries.User{ID: pgtype.UUID{Valid: true}}, GenerateInsightRequest{ + ToonPayload: "summary payload", + Lang: "en", + }) + if err != nil { + t.Fatalf("processInsight error = %v", err) + } + if status != http.StatusOK { + t.Fatalf("status = %d, want 200", status) + } + if calls != 2 { + t.Fatalf("generate calls = %d, want 2", calls) + } + var got GenerateInsightResponse + if err := json.Unmarshal(body, &got); err != nil { + t.Fatalf("decode body: %v", err) + } + if got.Title != "Snapshot ready" { + t.Fatalf("title = %q, want Snapshot ready", got.Title) + } + if !strings.Contains(got.Body, "try again later") { + t.Fatalf("body = %q, want fallback copy", got.Body) + } +} diff --git a/internal/handlers/logs.go b/internal/handlers/logs.go new file mode 100644 index 0000000..01d0a3b --- /dev/null +++ b/internal/handlers/logs.go @@ -0,0 +1,55 @@ +package handlers + +import ( + "fmt" + "net/http" + "time" + + "github.com/labstack/echo/v4" +) + +// LogsStreamHandler streams live log lines to the client via SSE. +// Protected by jwt + admin middleware (registered in handlers.go). +// Each log line is sent as: data: \n\n +func (S *Server) LogsStreamHandler(c echo.Context) error { + w := c.Response().Writer + flusher, ok := w.(http.Flusher) + if !ok { + return c.JSON(http.StatusInternalServerError, errResponse("streaming not supported", "STREAM_UNSUPPORTED")) + } + + // Disable the server's write deadline for this connection — SSE is long-lived + // and Echo's 30s WriteTimeout would otherwise kill the stream mid-session. + rc := http.NewResponseController(w) + if err := rc.SetWriteDeadline(time.Time{}); err != nil { + return c.JSON(http.StatusInternalServerError, errResponse("streaming not supported", "STREAM_UNSUPPORTED")) + } + + c.Response().Header().Set("Content-Type", "text/event-stream") + c.Response().Header().Set("Cache-Control", "no-cache") + c.Response().Header().Set("Connection", "keep-alive") + // Tells nginx to disable proxy buffering for this response. + // No nginx config change needed. + c.Response().Header().Set("X-Accel-Buffering", "no") + c.Response().WriteHeader(http.StatusOK) + flusher.Flush() + + ch := S.Broadcaster.Subscribe() + defer S.Broadcaster.Unsubscribe(ch) + + ctx := c.Request().Context() + for { + select { + case <-ctx.Done(): + return nil + case line, ok := <-ch: + if !ok { + return nil + } + if _, err := fmt.Fprintf(w, "data: %s\n\n", line); err != nil { + return nil + } + flusher.Flush() + } + } +} diff --git a/internal/handlers/parse.go b/internal/handlers/parse.go index 36a2629..df88ab7 100644 --- a/internal/handlers/parse.go +++ b/internal/handlers/parse.go @@ -1,70 +1,14 @@ package handlers import ( - "encoding/json" "errors" - "fmt" - "math" "net/http" - "numex-api/internal/db/queries" "numex-api/internal/models" "numex-api/internal/msg" - "numex-api/internal/utils" - "time" "github.com/labstack/echo/v4" ) -// geminiSystemInstruction is the code-level fallback used when the DB has no -// active "transaction_parse" prompt. The DB version (ai_prompts table) takes -// precedence and can be updated by admins without a deployment. -const geminiSystemInstruction = `You are a multilingual financial transaction parser for a personal finance app. - -Your job: extract structured transaction data from user speech transcripts. -The transcript comes from speech-to-text in a single language and may contain MULTIPLE distinct transactions. -User-created names (categories, balances) may be in a DIFFERENT language -than the transcript — use semantic understanding to match across languages. - -RULES: -1. AMOUNT: Extract the numeric amount. Handle formats: "50000", "50,000", - "50 000", "пятьдесят тысяч" (number words). Convert to minor units using - the currency's minor_unit value. Example: $50 with minor_unit=2 → 5000. -2. CURRENCY: If user explicitly names a currency ("dollars", "сум", "рубли", - "euro"), use that. Otherwise use the provided default currency. - IMPORTANT: The user's spoken language does NOT imply a currency. - Only override the default when the user explicitly names a different currency. -3. TYPE: Default to "expense". Only mark as "income" if user clearly - indicates receiving/earning money ("received", "earned", "получил", - "зарплата", "olganim"). -4. CATEGORY: Match semantically to the provided category list. - Example: "lunch" → "Food & Dining", "такси" → "Transport". - If no confident match, return null. -5. BALANCE: Match semantically to the provided balance list. - The user might say "from my cash" or "с карты" or "kartadan". - Balance names may be in a different language than the transcript — - match by meaning, not exact words. - If no confident match or not mentioned, use the balance named "Default" - from the provided list. -6. MERCHANT: Extract merchant/vendor name if mentioned (e.g., "Korzinka", - "Яндекс Такси"). Return null if not mentioned. -7. OCCURRED_AT: If the user explicitly mentions a time ("yesterday", "2 days ago"), - calculate the ISO-8601 timestamp based on the provided TIMEZONE and CURRENT_TIME. - If not mentioned, return the CURRENT_TIME timestamp. -8. CONFIDENCE: Return 0.0–1.0 based on how certain you are about - the OVERALL parse. Below 0.7 = likely needs user correction. - -DEBT RULES: -- Detect any situation where money is owed between the user and another person from the meaning of what was said, regardless of language or phrasing. -- Each new debt MUST produce both a "debts" entry AND a corresponding paired transaction. Multiple separate debts each get their own entry and paired transaction. -- Each debt: {"counterparty": "Name", "direction": "lent"|"owed", "amount_minor": , "currency": "XXX", "note": null, "confidence": 0.0-1.0} -- direction "lent" = user gave money out → paired transaction type = "expense". -- direction "owed" = user received money → paired transaction type = "income". -- For each paired transaction: amount_minor/currency/occurred_at same as the debt; category = match "Debts" from list or user-named category or null; balance = best match from list, fallback to "Default"; confidence same as the debt. -- If OPEN_DEBTS context is provided and user mentions repayment, return a transaction AND a "debt_transactions" entry: {"debt_counterparty": "Name", "transaction_index": , "confidence": 0.0-1.0}. If confidence < 0.7, create a standalone debt instead. - -RESPOND WITH ONLY valid JSON with this structure: -{"transactions": [...], "debts": [...], "debt_transactions": [...], "language": "", "transcript": ""}` - func (S *Server) ParseTransactionHandler(c echo.Context) error { ctx := c.Request().Context() var req models.ParseTransactionRequest @@ -84,218 +28,10 @@ func (S *Server) ParseTransactionHandler(c echo.Context) error { if err := S.Validate.Struct(req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"message": msg.ErrInvalidReqPayload}) } - - startTime := time.Now() - - // Use user's UI language for category/balance translations sent to Gemini - voiceLang := utils.LangPrefix(user.UiLanguage) - - // Load categories with translated names - categories, err := S.Queries.GetCategoriesByUserID(ctx, queries.GetCategoriesByUserIDParams{ - UserID: user.ID, - Lang: voiceLang, - }) - if err != nil { - S.LogErr(c, op, err) - return c.JSON(http.StatusInternalServerError, map[string]string{"message": msg.ErrInternalServerError}) - } - - // Load balances with translated names - balances, err := S.Queries.GetBalancesByUserID(ctx, queries.GetBalancesByUserIDParams{ - UserID: user.ID, - Lang: voiceLang, - }) - if err != nil { - S.LogErr(c, op, err) - return c.JSON(http.StatusInternalServerError, map[string]string{"message": msg.ErrInternalServerError}) - } - - // Load active currencies - currencies, err := S.Queries.GetActiveCurrencies(ctx) - if err != nil { - S.LogErr(c, op, err) - return c.JSON(http.StatusInternalServerError, map[string]string{"message": msg.ErrInternalServerError}) - } - - // Find minor_unit for request currency - minorUnit := 0 - for _, cur := range currencies { - if cur.Code == req.Currency { - minorUnit = int(cur.MinorUnit) - break - } - } - - // Build category list for prompt - catList := make([]map[string]string, len(categories)) - catIDSet := make(map[string]bool) - for i, cat := range categories { - idStr := fmt.Sprintf("%x-%x-%x-%x-%x", cat.ID.Bytes[0:4], cat.ID.Bytes[4:6], cat.ID.Bytes[6:8], cat.ID.Bytes[8:10], cat.ID.Bytes[10:16]) - catList[i] = map[string]string{"id": idStr, "name": cat.DisplayTitle} - catIDSet[idStr] = true - } - - // Build balance list for prompt - balList := make([]map[string]string, len(balances)) - balIDSet := make(map[string]bool) - for i, bal := range balances { - idStr := fmt.Sprintf("%x-%x-%x-%x-%x", bal.ID.Bytes[0:4], bal.ID.Bytes[4:6], bal.ID.Bytes[6:8], bal.ID.Bytes[8:10], bal.ID.Bytes[10:16]) - balList[i] = map[string]string{"id": idStr, "name": bal.DisplayName, "currency": bal.Currency} - balIDSet[idStr] = true - } - - // Build currency list for prompt - curList := make([]map[string]any, len(currencies)) - for i, cur := range currencies { - curList[i] = map[string]any{"code": cur.Code, "minor_unit": cur.MinorUnit} - } - - catJSON, _ := json.Marshal(catList) - balJSON, _ := json.Marshal(balList) - curJSON, _ := json.Marshal(curList) - - userPrompt := fmt.Sprintf( - "%s\n"+ - "%s (minor_unit: %d)\n"+ - "%s\n"+ - "%s\n\n"+ - "\n%s\n\n\n"+ - "\n%s\n\n\n"+ - "\n%s\n\n\n"+ - "%s", - voiceLang, req.Currency, minorUnit, req.Timezone, - time.Now().UTC().Format(time.RFC3339), - string(catJSON), string(balJSON), string(curJSON), req.Text, - ) - - if user.ContextSummary != nil && *user.ContextSummary != "" { - userPrompt += "\n\n\n" + *user.ContextSummary + "\n" - } - - // ── Gemini client (uses active key from DB) ─────────────────────────────── - client, err := S.Gemini.CreateClient(ctx) + statusCode, body, err := S.processTextParse(ctx, user, req) if err != nil { S.LogErr(c, op, err) - return c.JSON(http.StatusInternalServerError, errResponse(msg.ErrParseTransactionFailed, msg.CodeParseTransactionFailed)) + return c.JSON(http.StatusInternalServerError, errResponse(msg.ErrInternalServerError, msg.CodeInternalError)) } - - // ── Prompt from DB (fallback to hardcoded constant) ─────────────────────── - promptDb, err := S.Queries.GetActivePromptByName(ctx, "transaction_parse") - if err != nil { - S.LogErr(c, op, err) - return c.JSON(http.StatusInternalServerError, errResponse(msg.ErrInternalServerError, "-1")) - } - - if promptDb.SystemPrompt == "" { - promptDb.SystemPrompt = geminiSystemInstruction - } - - cfg := client.BuildGeminiConfig(promptDb, "application/json") - - resp, err := client.Generate(ctx, userPrompt, cfg, promptDb.Model) - if err != nil { - S.LogErr(c, op, err) - - ms := time.Since(startTime).Milliseconds() - if ms > math.MaxInt32 { - ms = math.MaxInt32 - } - latencyMs := int32(ms) - _, _ = S.Queries.CreateParseAttempt(ctx, queries.CreateParseAttemptParams{ - UserID: user.ID, - Language: voiceLang, - Status: "failed", - LatencyMs: &latencyMs, - }) - - return c.JSON(http.StatusInternalServerError, errResponse(msg.ErrParseTransactionFailed, msg.CodeParseTransactionFailed)) - } - - text := utils.CleanGeminiResponse(resp.Text()) - - // Parse Gemini response - var parsed models.MultiParseResponse - if err := json.Unmarshal([]byte(text), &parsed); err != nil { - if utils.LooksLikeTruncation(text) { - retryPrompt := userPrompt + "\n\nIMPORTANT: Previous response was truncated. Be concise. Truncate transcript field if needed but always produce complete valid JSON." - retryResp, retryErr := client.Generate(ctx, retryPrompt, cfg, promptDb.Model) - if retryErr == nil { - retryText := utils.CleanGeminiResponse(retryResp.Text()) - if jsonErr := json.Unmarshal([]byte(retryText), &parsed); jsonErr == nil { - goto parsedOK - } - } - } - S.LogErr(c, op, fmt.Errorf("gemini response parse error: %w, response: %s", err, text)) - - ms := time.Since(startTime).Milliseconds() - if ms > math.MaxInt32 { - ms = math.MaxInt32 - } - latencyMs := int32(ms) - _, _ = S.Queries.CreateParseAttempt(ctx, queries.CreateParseAttemptParams{ - UserID: user.ID, - Language: voiceLang, - Status: "failed", - LatencyMs: &latencyMs, - }) - - return c.JSON(http.StatusInternalServerError, errResponse(msg.ErrParseTransactionFailed, msg.CodeParseTransactionFailed)) - } -parsedOK: - - totalConfidence := 0.0 - for i := range parsed.Transactions { - parsed.Transactions[i].RawQuery = req.Text - totalConfidence += parsed.Transactions[i].Confidence - - // Validate returned category_id exists in user's categories - if parsed.Transactions[i].CategoryID != nil && !catIDSet[*parsed.Transactions[i].CategoryID] { - parsed.Transactions[i].CategoryID = nil - } - - // Validate returned balance_id exists in user's balances - if parsed.Transactions[i].BalanceID != nil { - if !balIDSet[*parsed.Transactions[i].BalanceID] { - parsed.Transactions[i].BalanceID = nil - } else { - // Verify currency match - for _, bal := range balances { - idStr := fmt.Sprintf("%x-%x-%x-%x-%x", bal.ID.Bytes[0:4], bal.ID.Bytes[4:6], bal.ID.Bytes[6:8], bal.ID.Bytes[8:10], bal.ID.Bytes[10:16]) - if idStr == *parsed.Transactions[i].BalanceID && bal.Currency != parsed.Transactions[i].Currency { - parsed.Transactions[i].BalanceID = nil - break - } - } - } - } - } - - avgConfidence := float32(0.0) - if len(parsed.Transactions) > 0 { - avgConfidence = float32(totalConfidence / float64(len(parsed.Transactions))) - } - - // Log parse attempt - ms := time.Since(startTime).Milliseconds() - if ms > math.MaxInt32 { - ms = math.MaxInt32 - } - latencyMs := int32(ms) - resultJSON, _ := json.Marshal(parsed) - status := "complete" - if avgConfidence < 0.7 { - status = "partial" - } - - _, _ = S.Queries.CreateParseAttempt(ctx, queries.CreateParseAttemptParams{ - UserID: user.ID, - Language: voiceLang, - Status: status, - Confidence: &avgConfidence, - LatencyMs: &latencyMs, - Result: resultJSON, - }) - - return c.JSON(http.StatusOK, parsed) + return c.JSONBlob(statusCode, body) } diff --git a/internal/handlers/parse_process.go b/internal/handlers/parse_process.go new file mode 100644 index 0000000..5f3635b --- /dev/null +++ b/internal/handlers/parse_process.go @@ -0,0 +1,349 @@ +package handlers + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "math" + "net/http" + "strings" + "time" + + "numex-api/internal/clients" + "numex-api/internal/db/queries" + "numex-api/internal/models" + "numex-api/internal/msg" + "numex-api/internal/utils" + + "github.com/jackc/pgx/v5/pgtype" +) + +func (S *Server) processTextParse(ctx context.Context, user queries.User, req models.ParseTransactionRequest) (int, []byte, error) { + startTime := time.Now() + voiceLang := utils.LangPrefix(user.UiLanguage) + + categories, err := S.Queries.GetCategoriesByUserID(ctx, queries.GetCategoriesByUserIDParams{UserID: user.ID, Lang: voiceLang}) + if err != nil { + return http.StatusInternalServerError, nil, err + } + balances, err := S.Queries.GetBalancesByUserID(ctx, queries.GetBalancesByUserIDParams{UserID: user.ID, Lang: voiceLang}) + if err != nil { + return http.StatusInternalServerError, nil, err + } + currencies, err := S.Queries.GetActiveCurrencies(ctx) + if err != nil { + return http.StatusInternalServerError, nil, err + } + + minorUnit := 0 + for _, cur := range currencies { + if cur.Code == req.Currency { + minorUnit = int(cur.MinorUnit) + break + } + } + + catList := make([]map[string]string, len(categories)) + catIDSet := make(map[string]bool) + for i, cat := range categories { + idStr := fmt.Sprintf("%x-%x-%x-%x-%x", cat.ID.Bytes[0:4], cat.ID.Bytes[4:6], cat.ID.Bytes[6:8], cat.ID.Bytes[8:10], cat.ID.Bytes[10:16]) + catList[i] = map[string]string{ + "id": idStr, + "name": cat.DisplayTitle, + "canonical_name": cat.Title, + } + catIDSet[idStr] = true + } + balList := make([]map[string]string, len(balances)) + balIDSet := make(map[string]bool) + for i, bal := range balances { + idStr := fmt.Sprintf("%x-%x-%x-%x-%x", bal.ID.Bytes[0:4], bal.ID.Bytes[4:6], bal.ID.Bytes[6:8], bal.ID.Bytes[8:10], bal.ID.Bytes[10:16]) + balList[i] = map[string]string{"id": idStr, "name": bal.DisplayName, "currency": bal.Currency} + balIDSet[idStr] = true + } + curList := make([]map[string]any, len(currencies)) + for i, cur := range currencies { + curList[i] = map[string]any{"code": cur.Code, "minor_unit": cur.MinorUnit} + } + + catJSON, _ := json.Marshal(catList) + balJSON, _ := json.Marshal(balList) + curJSON, _ := json.Marshal(curList) + userPrompt := fmt.Sprintf( + "%s\n"+ + "%s (minor_unit: %d)\n"+ + "%s\n"+ + "%s\n\n"+ + "\n%s\n\n\n"+ + "\n%s\n\n\n"+ + "\n%s\n\n\n"+ + "%s", + voiceLang, req.Currency, minorUnit, req.Timezone, + time.Now().UTC().Format(time.RFC3339), + string(catJSON), string(balJSON), string(curJSON), req.Text, + ) + openDebtsCtx, _ := S.Queries.GetOpenDebtsByUserID(ctx, user.ID) + recentMerchants, _ := S.Queries.GetRecentMerchantsByUserID(ctx, queries.GetRecentMerchantsByUserIDParams{UserID: user.ID, Lang: voiceLang}) + userContext := "" + if user.ContextSummary != nil { + userContext = *user.ContextSummary + } + userPrompt = appendParserContext(userPrompt, openDebtsCtx, recentMerchants, userContext) + + client, err := S.Gemini.CreateClient(ctx) + if err != nil { + return http.StatusInternalServerError, nil, err + } + promptDb, err := S.Queries.GetActivePromptByName(ctx, "transaction_parse") + if err != nil { + return http.StatusInternalServerError, nil, err + } + if promptDb.SystemPrompt == "" { + return http.StatusInternalServerError, nil, fmt.Errorf("empty transaction_parse prompt") + } + cfg := client.BuildGeminiConfig(promptDb, "application/json") + resp, err := client.Generate(ctx, userPrompt, cfg, promptDb.Model) + if err != nil { + ms := time.Since(startTime).Milliseconds() + if ms > math.MaxInt32 { + ms = math.MaxInt32 + } + latencyMs := int32(ms) + _, _ = S.Queries.CreateParseAttempt(ctx, queries.CreateParseAttemptParams{ + UserID: user.ID, Language: voiceLang, Status: "failed", LatencyMs: &latencyMs, + }) + if errors.Is(err, clients.ErrGeminiTemporarilyUnavailable) { + body, _ := json.Marshal(errResponse(msg.ErrSystemBusy, msg.CodeSystemBusy)) + return http.StatusServiceUnavailable, body, nil + } + body, _ := json.Marshal(errResponse(msg.ErrParseTransactionFailed, msg.CodeParseTransactionFailed)) + return http.StatusInternalServerError, body, nil + } + + text := utils.CleanGeminiResponse(resp.Text()) + var parsed models.MultiParseResponse + if err := json.Unmarshal([]byte(text), &parsed); err != nil { + if utils.LooksLikeTruncation(text) { + retryPrompt := userPrompt + "\n\nIMPORTANT: Previous response was truncated. Be concise. Truncate transcript field if needed but always produce complete valid JSON." + retryResp, retryErr := client.Generate(ctx, retryPrompt, cfg, promptDb.Model) + if retryErr == nil { + retryText := utils.CleanGeminiResponse(retryResp.Text()) + if jsonErr := json.Unmarshal([]byte(retryText), &parsed); jsonErr == nil { + goto parsedOK + } + } + } + ms := time.Since(startTime).Milliseconds() + if ms > math.MaxInt32 { + ms = math.MaxInt32 + } + latencyMs := int32(ms) + _, _ = S.Queries.CreateParseAttempt(ctx, queries.CreateParseAttemptParams{ + UserID: user.ID, Language: voiceLang, Status: "failed", LatencyMs: &latencyMs, + }) + body, _ := json.Marshal(errResponse(msg.ErrParseTransactionFailed, msg.CodeParseTransactionFailed)) + return http.StatusInternalServerError, body, nil + } +parsedOK: + totalConfidence := 0.0 + for i := range parsed.Transactions { + parsed.Transactions[i].RawQuery = req.Text + totalConfidence += parsed.Transactions[i].Confidence + if parsed.Transactions[i].CategoryID != nil && !catIDSet[*parsed.Transactions[i].CategoryID] { + parsed.Transactions[i].CategoryID = nil + } + if parsed.Transactions[i].BalanceID != nil { + if !balIDSet[*parsed.Transactions[i].BalanceID] { + parsed.Transactions[i].BalanceID = nil + } else { + for _, bal := range balances { + idStr := fmt.Sprintf("%x-%x-%x-%x-%x", bal.ID.Bytes[0:4], bal.ID.Bytes[4:6], bal.ID.Bytes[6:8], bal.ID.Bytes[8:10], bal.ID.Bytes[10:16]) + if idStr == *parsed.Transactions[i].BalanceID && bal.Currency != parsed.Transactions[i].Currency { + parsed.Transactions[i].BalanceID = nil + break + } + } + } + } + } + parsed.DebtBundles = validDebtBundleCandidates(parsed.DebtBundles) + if len(parsed.DebtBundles) == 0 { + parsed.DebtBundles = debtBundleCandidatesFromDebts(parsed.Debts, balances, "chat_manual") + } + parsed.Transactions = removeDebtBundleTransactions(parsed.Transactions, parsed.DebtBundles) + avgConfidence := float32(0.0) + if len(parsed.Transactions) > 0 { + avgConfidence = float32(totalConfidence / float64(len(parsed.Transactions))) + } + ms := time.Since(startTime).Milliseconds() + if ms > math.MaxInt32 { + ms = math.MaxInt32 + } + latencyMs := int32(ms) + resultJSON, _ := json.Marshal(parsed) + status := "complete" + if avgConfidence < 0.7 { + status = "partial" + } + _, _ = S.Queries.CreateParseAttempt(ctx, queries.CreateParseAttemptParams{ + UserID: user.ID, Language: voiceLang, Status: status, Confidence: &avgConfidence, LatencyMs: &latencyMs, Result: resultJSON, + }) + body, _ := json.Marshal(parsed) + return http.StatusOK, body, nil +} + +func debtBundleCandidatesFromDebts(debts []models.GeminiDebtItem, balances []queries.GetBalancesByUserIDRow, source string) []models.GeminiDebtBundleCandidate { + if len(debts) == 0 { + return nil + } + candidates := make([]models.GeminiDebtBundleCandidate, 0, len(debts)) + for _, debt := range debts { + if debt.Confidence < 0.7 || debt.AmountMinor <= 0 || len(debt.Currency) != 3 { + continue + } + counterparty := strings.TrimSpace(debt.Counterparty) + if counterparty == "" || len(counterparty) > 120 { + continue + } + if debt.Direction != "lent" && debt.Direction != "owed" { + continue + } + split := models.DebtBundleSplitRequest{ + BalanceID: debtBundleCandidateBalanceID(balances, debt.Currency), + AmountMinor: debt.AmountMinor, + Currency: debt.Currency, + } + candidates = append(candidates, models.GeminiDebtBundleCandidate{ + Kind: "creation", + Direction: debt.Direction, + Counterparty: counterparty, + AmountMinor: debt.AmountMinor, + Currency: debt.Currency, + ImpactAmountMinor: debt.AmountMinor, + ImpactCurrency: debt.Currency, + IncludeInAnalytics: false, + Note: strings.TrimSpace(debt.Note), + Source: source, + Splits: []models.DebtBundleSplitRequest{split}, + Confidence: debt.Confidence, + }) + } + return candidates +} + +func validDebtBundleCandidates(candidates []models.GeminiDebtBundleCandidate) []models.GeminiDebtBundleCandidate { + if len(candidates) == 0 { + return nil + } + valid := make([]models.GeminiDebtBundleCandidate, 0, len(candidates)) + for _, candidate := range candidates { + if isValidDebtBundleCandidate(candidate) { + valid = append(valid, candidate) + } + } + return valid +} + +func isValidDebtBundleCandidate(candidate models.GeminiDebtBundleCandidate) bool { + if candidate.Kind != "creation" && candidate.Kind != "repayment" { + return false + } + if candidate.Source == "" || candidate.AmountMinor <= 0 || candidate.ImpactAmountMinor <= 0 { + return false + } + if len(candidate.Currency) != 3 || len(candidate.ImpactCurrency) != 3 { + return false + } + if candidate.Direction != "lent" && candidate.Direction != "owed" { + return false + } + if strings.TrimSpace(candidate.Counterparty) == "" || len(candidate.Splits) == 0 { + return false + } + if candidate.Confidence > 0 && candidate.Confidence < 0.7 { + return false + } + if candidate.Kind == "repayment" && strings.TrimSpace(candidate.DebtID) == "" { + return false + } + for _, split := range candidate.Splits { + if split.AmountMinor <= 0 || len(split.Currency) != 3 { + return false + } + } + return true +} + +func debtBundleCandidateBalanceID(balances []queries.GetBalancesByUserIDRow, currency string) string { + matches := 0 + onlyMatch := "" + for _, balance := range balances { + if balance.Currency == currency { + matches++ + onlyMatch = formatPGUUID(balance.ID) + } + } + if matches == 1 { + return onlyMatch + } + return "" +} + +func debtBundleCandidatesFromRepayments( + links []models.GeminiDebtTransactionLink, + transactions []models.ParseTransactionResponse, + balances []queries.GetBalancesByUserIDRow, +) []models.GeminiDebtBundleCandidate { + candidates := make([]models.GeminiDebtBundleCandidate, 0, len(links)) + for _, link := range links { + if link.Confidence < 0.7 || link.TransactionIndex < 0 || link.TransactionIndex >= len(transactions) { + continue + } + txn := transactions[link.TransactionIndex] + if txn.AmountMinor <= 0 || len(txn.Currency) != 3 { + continue + } + balanceID := "" + if txn.BalanceID != nil { + balanceID = *txn.BalanceID + } + if balanceID == "" { + balanceID = debtBundleCandidateBalanceID(balances, txn.Currency) + } + candidates = append(candidates, models.GeminiDebtBundleCandidate{ + Kind: "repayment", + Direction: repaymentDirectionFromTransaction(txn.Type), + Counterparty: strings.TrimSpace(link.DebtCounterparty), + AmountMinor: txn.AmountMinor, + Currency: txn.Currency, + ImpactAmountMinor: txn.AmountMinor, + ImpactCurrency: txn.Currency, + IncludeInAnalytics: false, + Source: "voice", + Splits: []models.DebtBundleSplitRequest{ + { + BalanceID: balanceID, + AmountMinor: txn.AmountMinor, + Currency: txn.Currency, + }, + }, + Confidence: link.Confidence, + }) + } + return candidates +} + +func repaymentDirectionFromTransaction(txnType string) string { + if txnType == "income" { + return "lent" + } + return "owed" +} + +func formatPGUUID(id pgtype.UUID) string { + if !id.Valid { + return "" + } + return fmt.Sprintf("%x-%x-%x-%x-%x", id.Bytes[0:4], id.Bytes[4:6], id.Bytes[6:8], id.Bytes[8:10], id.Bytes[10:16]) +} diff --git a/internal/handlers/parser_context.go b/internal/handlers/parser_context.go new file mode 100644 index 0000000..f968495 --- /dev/null +++ b/internal/handlers/parser_context.go @@ -0,0 +1,40 @@ +package handlers + +import ( + "encoding/json" + "fmt" + + "numex-api/internal/db/queries" +) + +func appendParserContext( + userPrompt string, + openDebts []queries.GetOpenDebtsByUserIDRow, + recentMerchants []queries.GetRecentMerchantsByUserIDRow, + userContext string, +) string { + if len(openDebts) > 0 { + openDebtsJSON, _ := json.Marshal(openDebts) + userPrompt += fmt.Sprintf("\n\n\n%s\n", string(openDebtsJSON)) + } + if userContext != "" { + userPrompt += "\n\n\n" + userContext + "\n" + } + if len(recentMerchants) > 0 { + merchantList := make([]map[string]any, len(recentMerchants)) + for i, m := range recentMerchants { + var merchantName string + if m.Merchant != nil { + merchantName = *m.Merchant + } + merchantList[i] = map[string]any{ + "merchant": merchantName, + "category": m.CategoryName, + "count": m.Frequency, + } + } + merchantsJSON, _ := json.Marshal(merchantList) + userPrompt += "\n\n\n" + string(merchantsJSON) + "\n" + } + return userPrompt +} diff --git a/internal/handlers/paywall.go b/internal/handlers/paywall.go index c3903e8..c9581cc 100644 --- a/internal/handlers/paywall.go +++ b/internal/handlers/paywall.go @@ -37,12 +37,15 @@ type paywallProviderGroup struct { } type paywallPlan struct { - ID string `json:"id"` - PlanID string `json:"plan_id"` - StoreProductID string `json:"store_product_id"` - BillingPeriod string `json:"billing_period"` - PriceMinor int64 `json:"price_minor"` - CurrencyCode string `json:"currency_code"` + ID string `json:"id"` + PlanID string `json:"plan_id"` + StoreProductID string `json:"store_product_id"` + BillingPeriod string `json:"billing_period"` + PriceMinor int64 `json:"price_minor"` + CurrencyCode string `json:"currency_code"` + TrialDays int32 `json:"trial_days"` + TrialInterval string `json:"trial_interval"` + TrialIntervalCount int32 `json:"trial_interval_count"` } type paywallResponse struct { @@ -116,12 +119,15 @@ func buildPaywallResponse(products []queries.StoreProduct) paywallResponse { } plansByProvider[provider] = append(plansByProvider[provider], paywallPlan{ - ID: p.ID.String(), - PlanID: p.PlanID, - StoreProductID: p.StoreProductID, - BillingPeriod: string(utils.NormalizeBillingPeriod(p.Period)), - PriceMinor: priceMinor, - CurrencyCode: currency, + ID: p.ID.String(), + PlanID: p.PlanID, + StoreProductID: p.StoreProductID, + BillingPeriod: string(utils.NormalizeBillingPeriod(p.Period)), + PriceMinor: priceMinor, + CurrencyCode: currency, + TrialDays: p.TrialDays, + TrialInterval: p.TrialInterval, + TrialIntervalCount: p.TrialIntervalCount, }) } @@ -196,7 +202,7 @@ func (S *Server) GetPaywallHandler(c echo.Context) error { filtered := make([]queries.StoreProduct, 0, len(products)) for _, p := range products { provider := strings.TrimSpace(p.Provider) - if provider == "payme" && !isPaymeEnabledForPaywall(S, c) { + if provider == "payme" { continue } if provider == "polar" && !isPolarEnabledForPaywall(S, c) { diff --git a/internal/handlers/paywall_test.go b/internal/handlers/paywall_test.go index 02cd5b2..e71e1cb 100644 --- a/internal/handlers/paywall_test.go +++ b/internal/handlers/paywall_test.go @@ -22,7 +22,7 @@ type paywallResponseJSON struct { func TestBuildPaywallResponse_GroupsAndSortsByProvider(t *testing.T) { products := []queries.StoreProduct{ - productRow(t, "polar", "polar_monthly", "monthly", 499, "USD", true), + productRowWithTrial(t, "polar", "polar_monthly", "monthly", 499, "USD", true, 12, "day", 12), productRow(t, "android", "android_yearly", "yearly", 3999, "USD", true), productRow(t, "ios", "ios_monthly", "monthly", 499, "USD", true), productRow(t, "android", "android_weekly", "weekly", 149, "USD", true), @@ -69,6 +69,14 @@ func TestBuildPaywallResponse_GroupsAndSortsByProvider(t *testing.T) { if got, want := []string{paymePlans[0].BillingPeriod, paymePlans[1].BillingPeriod}, []string{"weekly", "quarterly"}; !slicesEqual(got, want) { t.Fatalf("payme billing periods = %#v, want %#v", got, want) } + + polarPlans := resp.PlansByProvider["polar"] + if got, want := len(polarPlans), 1; got != want { + t.Fatalf("polar plans = %d, want %d", got, want) + } + if polarPlans[0].TrialInterval != "day" || polarPlans[0].TrialIntervalCount != 12 || polarPlans[0].TrialDays != 12 { + t.Fatalf("polar trial fields = %q/%d/%d, want day/12/12", polarPlans[0].TrialInterval, polarPlans[0].TrialIntervalCount, polarPlans[0].TrialDays) + } } func TestGetPaywallHandler_ReturnsProviderAwareCatalog(t *testing.T) { @@ -89,7 +97,7 @@ func TestGetPaywallHandler_ReturnsProviderAwareCatalog(t *testing.T) { getActiveStoreProductsForPaywall = func(_ context.Context, _ *queries.Queries) ([]queries.StoreProduct, error) { return []queries.StoreProduct{ productRow(t, "android", "android_monthly", "monthly", 499, "USD", true), - productRow(t, "polar", "polar_yearly", "yearly", 3999, "USD", true), + productRowWithTrial(t, "polar", "polar_yearly", "yearly", 3999, "USD", true, 12, "day", 12), }, nil } isPaymeEnabledForPaywall = func(_ *Server, _ echo.Context) bool { return true } @@ -138,6 +146,13 @@ func TestGetPaywallHandler_ReturnsProviderAwareCatalog(t *testing.T) { if _, ok := got.PlansByProvider["polar"]; !ok { t.Fatalf("missing polar plans in response") } + polarPlans := got.PlansByProvider["polar"] + if got, want := len(polarPlans), 1; got != want { + t.Fatalf("polar plans = %d, want %d", got, want) + } + if polarPlans[0].TrialInterval != "day" || polarPlans[0].TrialIntervalCount != 12 || polarPlans[0].TrialDays != 12 { + t.Fatalf("polar trial fields = %q/%d/%d, want day/12/12", polarPlans[0].TrialInterval, polarPlans[0].TrialIntervalCount, polarPlans[0].TrialDays) + } } func productRow(t *testing.T, provider, storeProductID, period string, priceMinor int64, currency string, active bool) queries.StoreProduct { @@ -169,6 +184,16 @@ func productRow(t *testing.T, provider, storeProductID, period string, priceMino } } +func productRowWithTrial(t *testing.T, provider, storeProductID, period string, priceMinor int64, currency string, active bool, trialDays int32, trialInterval string, trialIntervalCount int32) queries.StoreProduct { + t.Helper() + + row := productRow(t, provider, storeProductID, period, priceMinor, currency, active) + row.TrialDays = trialDays + row.TrialInterval = trialInterval + row.TrialIntervalCount = trialIntervalCount + return row +} + func slicesEqual[T comparable](got, want []T) bool { if len(got) != len(want) { return false @@ -260,8 +285,8 @@ func TestGetPaywallHandler_FiltersDisabledProviders(t *testing.T) { if _, ok := got.PlansByProvider["polar"]; ok { t.Fatal("expected polar absent from catalog when disabled, but it was present") } - if _, ok := got.PlansByProvider["payme"]; !ok { - t.Fatal("expected payme present in catalog when enabled, but it was absent") + if _, ok := got.PlansByProvider["payme"]; ok { + t.Fatal("expected payme absent from catalog after recurring billing removal, but it was present") } }) diff --git a/internal/handlers/subscription.go b/internal/handlers/subscription.go index f9dac1a..09479de 100644 --- a/internal/handlers/subscription.go +++ b/internal/handlers/subscription.go @@ -1,249 +1,25 @@ package handlers import ( - "fmt" "net/http" "numex-api/internal/db/queries" "numex-api/internal/msg" - "numex-api/internal/utils" - "strings" - "time" - "github.com/jackc/pgx/v5/pgtype" "github.com/labstack/echo/v4" ) -// --- Request/Response types --- - -type subscribePaymeRequest struct { - ProductID string `json:"productId" validate:"required"` - CardNumber string `json:"cardNumber" validate:"required,len=16"` - CardExpire string `json:"cardExpire" validate:"required,len=4"` -} - -type verifyPaymeCardRequest struct { - CardToken string `json:"cardToken" validate:"required"` - VerificationCode string `json:"verificationCode" validate:"required"` - ProductID string `json:"productId" validate:"required"` -} - -type updatePaymeCardRequest struct { - CardNumber string `json:"cardNumber" validate:"required,len=16"` - CardExpire string `json:"cardExpire" validate:"required,len=4"` -} - // --- Handlers --- // SubscribePaymeHandler initiates a Payme subscription — tokenizes card and requests SMS verification. // POST /api/v1/subscriptions/payme/subscribe func (S *Server) SubscribePaymeHandler(c echo.Context) error { - if !S.isPaymeEnabled() || S.Payme == nil { - return c.JSON(http.StatusServiceUnavailable, msgResponse(msg.ErrProviderUnavailable)) - } - const op = "SubscribePaymeHandler" - - user, err := S.getUserFromClaims(c, op) - if err != nil { - return claimsError(c) - } - - var req subscribePaymeRequest - if err := c.Bind(&req); err != nil { - return c.JSON(http.StatusBadRequest, msgResponse(msg.ErrInvalidReqPayload)) - } - if err := S.Validate.Struct(req); err != nil { - return c.JSON(http.StatusBadRequest, errResponse(msg.ErrInvalidReqPayload, msg.CodeValidationError)) - } - - if S.Payme == nil { - return c.JSON(http.StatusServiceUnavailable, msgResponse(msg.ErrPaymeUnavailable)) - } - - // Check if user already has an active subscription - _, subErr := S.Queries.GetActiveSubscriptionByUserID(c.Request().Context(), user.ID) - if subErr == nil { - return c.JSON(http.StatusConflict, msgResponse(msg.ErrAlreadySubscribed)) - } - - // Tokenize the card - cardToken, err := S.Payme.CardsCreate(req.CardNumber, req.CardExpire) - if err != nil { - S.LogErr(c, op, err) - return c.JSON(http.StatusBadGateway, msgResponse(msg.ErrCardProcessingFailed)) - } - - // Request SMS verification - if err := S.Payme.CardsGetVerifyCode(cardToken); err != nil { - S.LogErr(c, op, err) - return c.JSON(http.StatusBadGateway, msgResponse(msg.ErrVerifyCodeFailed)) - } - - _ = user // suppress unused warning - - return c.JSON(http.StatusOK, map[string]interface{}{ - "cardToken": cardToken, - "needsVerification": true, - }) + return c.JSON(http.StatusServiceUnavailable, msgResponse(msg.ErrProviderUnavailable)) } // VerifyPaymeCardHandler verifies the card and creates the subscription. // POST /api/v1/subscriptions/payme/verify func (S *Server) VerifyPaymeCardHandler(c echo.Context) error { - if !S.isPaymeEnabled() || S.Payme == nil { - return c.JSON(http.StatusServiceUnavailable, msgResponse(msg.ErrProviderUnavailable)) - } - const op = "VerifyPaymeCardHandler" - ctx := c.Request().Context() - - user, err := S.getUserFromClaims(c, op) - if err != nil { - return claimsError(c) - } - - var req verifyPaymeCardRequest - if err := c.Bind(&req); err != nil { - return c.JSON(http.StatusBadRequest, msgResponse(msg.ErrInvalidReqPayload)) - } - if err := S.Validate.Struct(req); err != nil { - return c.JSON(http.StatusBadRequest, errResponse(msg.ErrInvalidReqPayload, msg.CodeValidationError)) - } - - if S.Payme == nil { - return c.JSON(http.StatusServiceUnavailable, msgResponse(msg.ErrPaymeUnavailable)) - } - - // Verify card - if err := S.Payme.CardsVerify(req.CardToken, req.VerificationCode); err != nil { - S.LogErr(c, op, err) - return c.JSON(http.StatusBadRequest, msgResponse(msg.ErrInvalidVerificationCode)) - } - - // Get store product - product, err := S.Queries.GetStoreProductByID(ctx, pgtype.UUID{Bytes: uuidFromString(req.ProductID), Valid: true}) - if err != nil { - S.LogErr(c, op, err) - return c.JSON(http.StatusBadRequest, msgResponse(msg.ErrProductNotFound)) - } - - // Charge via Payme BEFORE DB mutations — if payment fails, no DB state changes - orderID := user.ID.Bytes[:] - var amountMinor int64 - if product.PriceMinor != nil { - amountMinor = *product.PriceMinor - } - - receiptID, err := S.Payme.ReceiptsCreate(amountMinor, fmt.Sprintf("%x", orderID), req.CardToken) - if err != nil { - S.LogErr(c, op, err) - return c.JSON(http.StatusBadGateway, errResponse(msg.ErrPaymentFailed, "PAYMENT_FAILED")) - } - - if err := S.Payme.ReceiptsPay(receiptID, req.CardToken); err != nil { - S.LogErr(c, op, err) - return c.JSON(http.StatusBadGateway, errResponse(msg.ErrPaymentFailed, "PAYMENT_FAILED")) - } - - // ── All DB mutations in one transaction ────────────────────────────── - tx, err := S.DB.Begin(ctx) - if err != nil { - S.LogErr(c, op, err) - return c.JSON(http.StatusInternalServerError, errResponse(msg.ErrDBTransactionStart, "DB_ERROR")) - } - defer func() { - if rbErr := tx.Rollback(ctx); rbErr != nil && rbErr.Error() != "tx is closed" { - S.LogErr(c, op, fmt.Errorf("rollback: %w", rbErr)) - } - }() - qtx := S.Queries.WithTx(tx) - - // Deactivate old cards, save new card - _ = qtx.DeactivatePaymeCardsByUserID(ctx, user.ID) - cardLast4 := req.CardToken[len(req.CardToken)-4:] - if len(req.CardToken) < 4 { - cardLast4 = "****" - } - - _, err = qtx.CreatePaymeCard(ctx, queries.CreatePaymeCardParams{ - UserID: user.ID, - CardToken: req.CardToken, - CardLast4: cardLast4, - CardExpire: "0000", // We don't have the raw expire at this point - }) - if err != nil { - S.LogErr(c, op, err) - return c.JSON(http.StatusInternalServerError, errResponse(msg.ErrInternalServerError, "DB_ERROR")) - } - - // Determine period - now := time.Now() - graceDays := int32(2) - anchorDay := utils.BillingAnchorDay(now, user.Timezone) - periodEnd := utils.ComputePeriodEnd(now, user.Timezone, product.Period) - graceUntil := utils.ComputeGraceUntil(periodEnd, user.Timezone, int(graceDays)) - billingTimezone := user.Timezone - - // Create subscription - sub, err := qtx.CreateSubscription(ctx, queries.CreateSubscriptionParams{ - UserID: user.ID, - PlanID: product.PlanID, - ProductID: product.ID, - Provider: "payme", - BillingPeriod: string(utils.NormalizeBillingPeriod(product.Period)), - ProviderSubscriptionID: nil, - Status: "active", - BillingAnchorDay: &anchorDay, - BillingTimezone: &billingTimezone, - GraceDays: graceDays, - GraceUntil: pgtype.Timestamptz{Time: graceUntil, Valid: true}, - PastDueSince: pgtype.Timestamptz{}, - CurrentPeriodStart: pgtype.Timestamptz{Time: now, Valid: true}, - CurrentPeriodEnd: pgtype.Timestamptz{Time: periodEnd, Valid: true}, - }) - if err != nil { - S.LogErr(c, op, err) - return c.JSON(http.StatusInternalServerError, errResponse(msg.ErrInternalServerError, "DB_ERROR")) - } - - // Upsert entitlement - if err := qtx.UpsertEntitlement(ctx, queries.UpsertEntitlementParams{ - UserID: user.ID, - PlanID: product.PlanID, - BillingPeriod: func() *string { - period := string(utils.NormalizeBillingPeriod(product.Period)) - return &period - }(), - ActiveUntil: pgtype.Timestamptz{Time: periodEnd, Valid: true}, - }); err != nil { - S.LogErr(c, op, err) - return c.JSON(http.StatusInternalServerError, errResponse(msg.ErrInternalServerError, "DB_ERROR")) - } - - // Create next billing job - currencyCode := "UZS" - if product.CurrencyCode != nil { - currencyCode = *product.CurrencyCode - } - if _, err := qtx.CreateBillingJob(ctx, queries.CreateBillingJobParams{ - SubscriptionID: sub.ID, - UserID: user.ID, - AmountMinor: amountMinor, - CurrencyCode: currencyCode, - RunAt: pgtype.Timestamptz{Time: periodEnd, Valid: true}, - }); err != nil { - S.LogErr(c, op, err) - return c.JSON(http.StatusInternalServerError, errResponse(msg.ErrInternalServerError, "DB_ERROR")) - } - - if err := tx.Commit(ctx); err != nil { - S.LogErr(c, op, err) - return c.JSON(http.StatusInternalServerError, errResponse(msg.ErrDBTransactionCommit, "DB_ERROR")) - } - - return c.JSON(http.StatusOK, map[string]interface{}{ - "subscription": sub, - "message": msg.MsgSubscriptionActivated, - "code": msg.CodeSubscriptionActivated, - }) + return c.JSON(http.StatusServiceUnavailable, msgResponse(msg.ErrProviderUnavailable)) } // CancelSubscriptionHandler cancels the user's active subscription. @@ -284,8 +60,8 @@ func (S *Server) CancelSubscriptionHandler(c echo.Context) error { return c.JSON(http.StatusOK, map[string]interface{}{"canceled": true}) case "payme": - // Payme billing is managed internally via billing_jobs worker. - // Setting cancel_at_period_end stops the next billing job. + // Payme recurring background billing has been removed; keep local + // subscription cancellation state only. _ = S.Queries.SetSubscriptionCancelAtPeriodEnd(ctx, queries.SetSubscriptionCancelAtPeriodEndParams{ ID: sub.ID, CancelAtPeriodEnd: true, @@ -355,60 +131,5 @@ func (S *Server) GetSubscriptionHandler(c echo.Context) error { // UpdatePaymeCardHandler updates the user's Payme card for future billing. // POST /api/v1/subscriptions/payme/update-card func (S *Server) UpdatePaymeCardHandler(c echo.Context) error { - if !S.isPaymeEnabled() || S.Payme == nil { - return c.JSON(http.StatusServiceUnavailable, msgResponse(msg.ErrProviderUnavailable)) - } - const op = "UpdatePaymeCardHandler" - ctx := c.Request().Context() - - user, err := S.getUserFromClaims(c, op) - if err != nil { - return claimsError(c) - } - - var req updatePaymeCardRequest - if err := c.Bind(&req); err != nil { - return c.JSON(http.StatusBadRequest, msgResponse(msg.ErrInvalidReqPayload)) - } - if err := S.Validate.Struct(req); err != nil { - return c.JSON(http.StatusBadRequest, errResponse(msg.ErrInvalidReqPayload, msg.CodeValidationError)) - } - - if S.Payme == nil { - return c.JSON(http.StatusServiceUnavailable, msgResponse(msg.ErrPaymeUnavailable)) - } - - // Tokenize new card - cardToken, err := S.Payme.CardsCreate(req.CardNumber, req.CardExpire) - if err != nil { - S.LogErr(c, op, err) - return c.JSON(http.StatusBadGateway, msgResponse(msg.ErrCardProcessingFailed)) - } - - // Request SMS verification - if err := S.Payme.CardsGetVerifyCode(cardToken); err != nil { - S.LogErr(c, op, err) - return c.JSON(http.StatusBadGateway, msgResponse(msg.ErrVerifyCodeFailed)) - } - - _ = ctx // referenced later when card is verified - _ = user - - return c.JSON(http.StatusOK, map[string]interface{}{ - "cardToken": cardToken, - "needsVerification": true, - }) -} - -// Helper to convert string UUID to [16]byte -func uuidFromString(s string) [16]byte { - var b [16]byte - // Simple hex parse for UUID - s = strings.ReplaceAll(s, "-", "") - if len(s) == 32 { - for i := 0; i < 16; i++ { - _, _ = fmt.Sscanf(s[i*2:i*2+2], "%02x", &b[i]) - } - } - return b + return c.JSON(http.StatusServiceUnavailable, msgResponse(msg.ErrProviderUnavailable)) } diff --git a/internal/handlers/sync.go b/internal/handlers/sync.go index 807af31..c20a718 100644 --- a/internal/handlers/sync.go +++ b/internal/handlers/sync.go @@ -44,9 +44,10 @@ func (S *Server) SyncHandler(c echo.Context) error { resp := models.SyncResponse{ PushResults: models.SyncPushResults{ - Categories: models.SyncPushEntityResult{Created: []string{}, Updated: []string{}, Deleted: []string{}, Errors: []models.SyncEntityError{}}, - Balances: models.SyncPushEntityResult{Created: []string{}, Updated: []string{}, Deleted: []string{}, Errors: []models.SyncEntityError{}}, - Transactions: models.SyncPushEntityResult{Created: []string{}, Updated: []string{}, Deleted: []string{}, Errors: []models.SyncEntityError{}}, + Categories: models.SyncPushEntityResult{Created: []string{}, Updated: []string{}, Deleted: []string{}, Errors: []models.SyncEntityError{}}, + Balances: models.SyncPushEntityResult{Created: []string{}, Updated: []string{}, Deleted: []string{}, Errors: []models.SyncEntityError{}}, + Transactions: models.SyncPushEntityResult{Created: []string{}, Updated: []string{}, Deleted: []string{}, Errors: []models.SyncEntityError{}}, + BalanceSnapshots: models.SyncPushEntityResult{Created: []string{}, Updated: []string{}, Deleted: []string{}, Errors: []models.SyncEntityError{}}, }, Pull: models.SyncPull{ Categories: []models.SyncCategory{}, @@ -119,6 +120,72 @@ func (S *Server) SyncHandler(c echo.Context) error { } } + // ── Push: Balances ──────────────────────────────────────────────────────── + for _, bal := range req.Push.Balances.Create { + balUUID, err := uuid.Parse(bal.ID) + if err != nil { + resp.PushResults.Balances.Errors = append(resp.PushResults.Balances.Errors, + models.SyncEntityError{ID: bal.ID, Error: "invalid_uuid"}) + continue + } + if bal.EncryptedInitialAmount == "" || bal.EncryptedName == "" { + resp.PushResults.Balances.Errors = append(resp.PushResults.Balances.Errors, + models.SyncEntityError{ID: bal.ID, Error: "encrypted_fields_required"}) + continue + } + + now := time.Now().UTC() + encryptedDescription := &bal.EncryptedDescription + if bal.EncryptedDescription == "" { + encryptedDescription = nil + } + + _, err = qtx.UpsertBalanceFromSync(ctx, queries.UpsertBalanceFromSyncParams{ + ID: pgtype.UUID{Bytes: balUUID, Valid: true}, + UserID: pgtype.UUID{Bytes: user.ID.Bytes, Valid: true}, + Name: "Balance", + Description: nil, + Currency: bal.Currency, + InitialAmountMinor: 0, + ColorToken: bal.ColorToken, + EncryptedName: &bal.EncryptedName, + EncryptedDescription: encryptedDescription, + EncryptedInitialAmount: &bal.EncryptedInitialAmount, + EncryptedBalanceSnapshot: nil, + SnapshotTxCount: nil, + SnapshotUpdatedAt: pgtype.Timestamptz{}, + IsSystem: false, + SortOrder: int32(bal.SortOrder), // #nosec G115 -- sort order bounded by UI validation + CreatedAt: pgtype.Timestamptz{Time: now, Valid: true}, + UpdatedAt: pgtype.Timestamptz{Time: now, Valid: true}, + }) + if err != nil { + resp.PushResults.Balances.Errors = append(resp.PushResults.Balances.Errors, + models.SyncEntityError{ID: bal.ID, Error: "create_failed"}) + } else { + resp.PushResults.Balances.Created = append(resp.PushResults.Balances.Created, bal.ID) + } + } + + for _, balID := range req.Push.Balances.Delete { + balUUID, err := uuid.Parse(balID) + if err != nil { + resp.PushResults.Balances.Errors = append(resp.PushResults.Balances.Errors, + models.SyncEntityError{ID: balID, Error: "invalid_uuid"}) + continue + } + err = qtx.SoftDeleteBalanceFromSync(ctx, queries.SoftDeleteBalanceFromSyncParams{ + ID: pgtype.UUID{Bytes: balUUID, Valid: true}, + UserID: pgtype.UUID{Bytes: user.ID.Bytes, Valid: true}, + }) + if err != nil { + resp.PushResults.Balances.Errors = append(resp.PushResults.Balances.Errors, + models.SyncEntityError{ID: balID, Error: "delete_failed"}) + } else { + resp.PushResults.Balances.Deleted = append(resp.PushResults.Balances.Deleted, balID) + } + } + // ── Push: Transactions ──────────────────────────────────────────────────── for _, txn := range req.Push.Transactions.Create { txnUUID, err := uuid.Parse(txn.ID) @@ -201,6 +268,35 @@ func (S *Server) SyncHandler(c echo.Context) error { } } + for _, snap := range req.Push.BalanceSnapshots { + balUUID, err := uuid.Parse(snap.BalanceID) + if err != nil { + resp.PushResults.BalanceSnapshots.Errors = append(resp.PushResults.BalanceSnapshots.Errors, + models.SyncEntityError{ID: snap.BalanceID, Error: "invalid_uuid"}) + continue + } + snapshotUpdatedAt, err := time.Parse(time.RFC3339, snap.SnapshotUpdatedAt) + if err != nil { + resp.PushResults.BalanceSnapshots.Errors = append(resp.PushResults.BalanceSnapshots.Errors, + models.SyncEntityError{ID: snap.BalanceID, Error: "invalid_snapshot_updated_at"}) + continue + } + snapshotTxCount := int32(snap.SnapshotTxCount) // #nosec G115 -- sync tx count bounded by local rows + err = qtx.UpdateBalanceSnapshotFromSync(ctx, queries.UpdateBalanceSnapshotFromSyncParams{ + ID: pgtype.UUID{Bytes: balUUID, Valid: true}, + UserID: pgtype.UUID{Bytes: user.ID.Bytes, Valid: true}, + EncryptedBalanceSnapshot: &snap.EncryptedSnapshot, + SnapshotTxCount: &snapshotTxCount, + SnapshotUpdatedAt: pgtype.Timestamptz{Time: snapshotUpdatedAt, Valid: true}, + }) + if err != nil { + resp.PushResults.BalanceSnapshots.Errors = append(resp.PushResults.BalanceSnapshots.Errors, + models.SyncEntityError{ID: snap.BalanceID, Error: "update_failed"}) + } else { + resp.PushResults.BalanceSnapshots.Updated = append(resp.PushResults.BalanceSnapshots.Updated, snap.BalanceID) + } + } + if err := tx.Commit(ctx); err != nil { S.LogErr(c, op, err) return c.JSON(http.StatusInternalServerError, map[string]string{"message": msg.ErrInternalServerError}) diff --git a/internal/handlers/transaction.go b/internal/handlers/transaction.go index a0fa1da..ba6bb51 100644 --- a/internal/handlers/transaction.go +++ b/internal/handlers/transaction.go @@ -264,9 +264,9 @@ func (S *Server) CreateTransactionHandler(c echo.Context) error { CategoryID: pgtype.UUID{Bytes: catUUID, Valid: true}, Type: req.Type, Currency: req.Currency, - Merchant: utils.StringToPointer(req.Merchant), - Note: utils.StringToPointer(req.Note), - RawQuery: nil, + Merchant: plaintextPtrUnlessEncrypted(req.Merchant, encryptedFields.merchant), + Note: plaintextPtrUnlessEncrypted(req.Note, encryptedFields.note), + RawQuery: plaintextPtrUnlessEncrypted(req.RawQuery, encryptedFields.rawQuery), EncryptedAmount: encryptedFields.amount, EncryptedMerchant: encryptedFields.merchant, EncryptedNote: encryptedFields.note, @@ -410,9 +410,9 @@ func (S *Server) UpdateTransactionHandler(c echo.Context) error { CategoryID: catID, Type: req.Type, Currency: req.Currency, - Merchant: utils.StringToPointer(req.Merchant), - Note: utils.StringToPointer(req.Note), - RawQuery: nil, + Merchant: plaintextPtrUnlessEncrypted(req.Merchant, encryptedFields.merchant), + Note: plaintextPtrUnlessEncrypted(req.Note, encryptedFields.note), + RawQuery: plaintextPtrUnlessEncrypted(req.RawQuery, encryptedFields.rawQuery), EncryptedAmount: encryptedAmount, EncryptedMerchant: encryptedFields.merchant, EncryptedNote: encryptedFields.note, @@ -436,6 +436,170 @@ func (S *Server) UpdateTransactionHandler(c echo.Context) error { return c.JSON(http.StatusOK, resp) } +func (S *Server) ReprocessTransactionHandler(c echo.Context) error { + ctx := c.Request().Context() + const op = "ReprocessTransaction" + + user, err := S.getUserFromClaims(c, op) + if err != nil { + if errors.Is(err, msg.ErrMissingUserClaims) { + return claimsError(c) + } + return c.JSON(http.StatusUnauthorized, map[string]string{"message": msg.ErrInvalidOrExpiredAccessToken}) + } + + txID, err := uuid.Parse(c.Param("id")) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"message": msg.ErrInvalidReqPayload}) + } + + var req models.ReprocessTransactionRequest + if err := c.Bind(&req); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"message": msg.ErrInvalidReqPayload}) + } + if err := S.Validate.Struct(req); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"message": msg.ErrInvalidReqPayload}) + } + if req.Timezone == "" { + req.Timezone = user.Timezone + if req.Timezone == "" { + req.Timezone = "UTC" + } + } + + lang := utils.LangPrefix(user.UiLanguage) + current, err := S.Queries.GetTransactionByID(ctx, queries.GetTransactionByIDParams{ + ID: pgtype.UUID{Bytes: txID, Valid: true}, + UserID: user.ID, + Lang: lang, + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return c.JSON(http.StatusNotFound, errResponse(msg.ErrTransactionNotFound, msg.CodeTransactionNotFound)) + } + S.LogErr(c, op, err) + return c.JSON(http.StatusInternalServerError, map[string]string{"message": msg.ErrInternalServerError}) + } + + statusCode, body, err := S.processTextParse(ctx, user, models.ParseTransactionRequest{ + Text: req.RawQuery, + Currency: req.Currency, + Timezone: req.Timezone, + }) + if err != nil { + S.LogErr(c, op, err) + return c.JSON(http.StatusInternalServerError, errResponse(msg.ErrInternalServerError, msg.CodeInternalError)) + } + if statusCode < 200 || statusCode >= 300 { + return c.JSONBlob(statusCode, body) + } + + var parsed models.MultiParseResponse + if err := json.Unmarshal(body, &parsed); err != nil { + return c.JSON(http.StatusUnprocessableEntity, errResponse(msg.ErrParseTransactionFailed, msg.CodeParseTransactionFailed)) + } + if len(parsed.Transactions) == 0 || parsed.Transactions[0].Confidence < 0.7 { + return c.JSON(http.StatusOK, map[string]any{ + "message": msg.MsgLowConfidenceParse, + "code": msg.CodeLowConfidenceParse, + "parsed": parsed, + }) + } + pt := parsed.Transactions[0] + + catID := current.CategoryID + if pt.CategoryID != nil { + if parsedUUID, err := uuid.Parse(*pt.CategoryID); err == nil { + catID = pgtype.UUID{Bytes: parsedUUID, Valid: true} + } + } + balID := current.BalanceID + if pt.BalanceID != nil { + if parsedUUID, err := uuid.Parse(*pt.BalanceID); err == nil { + balID = pgtype.UUID{Bytes: parsedUUID, Valid: true} + } + } + occurredAt := current.OccurredAt + if pt.OccurredAt != "" { + if parsedTime, err := time.Parse(time.RFC3339, pt.OccurredAt); err == nil { + occurredAt = pgtype.Timestamptz{Time: parsedTime, Valid: true} + } + } + + var userPubKey []byte + if user.HasEncryptionKeys && user.PublicKey != nil { + if decoded, err := base64DecodeKey(*user.PublicKey); err == nil && len(decoded) == 32 { + userPubKey = decoded + } + } + if len(userPubKey) != 32 { + return c.JSON(http.StatusConflict, msgResponse(msg.ErrEncryptionKeysRequired)) + } + + var encAmount, encMerchant, encRawQuery *string + if enc, err := utils.EncryptForUser(userPubKey, []byte(fmt.Sprintf("%d", pt.AmountMinor))); err == nil { + encAmount = &enc + } + if pt.Merchant != nil && *pt.Merchant != "" { + if enc, err := utils.EncryptForUser(userPubKey, []byte(*pt.Merchant)); err == nil { + encMerchant = &enc + } + } + if req.RawQuery != "" { + if enc, err := utils.EncryptForUser(userPubKey, []byte(req.RawQuery)); err == nil { + encRawQuery = &enc + } + } + merchant := "" + if pt.Merchant != nil { + merchant = *pt.Merchant + } + encryptedAmount := "" + if encAmount != nil { + encryptedAmount = *encAmount + } + + txn, err := S.Queries.UpdateTransaction(ctx, queries.UpdateTransactionParams{ + ID: pgtype.UUID{Bytes: txID, Valid: true}, + UserID: user.ID, + CategoryID: catID, + Type: pt.Type, + Currency: pt.Currency, + Merchant: plaintextPtrUnlessEncrypted(merchant, encMerchant), + Note: nil, + RawQuery: plaintextPtrUnlessEncrypted(req.RawQuery, encRawQuery), + EncryptedAmount: encryptedAmount, + EncryptedMerchant: encMerchant, + EncryptedNote: nil, + EncryptedRawQuery: encRawQuery, + BalanceID: balID, + OccurredAt: occurredAt, + Version: current.Version, + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return c.JSON(http.StatusConflict, errResponse(msg.ErrTransactionConflict, msg.CodeTransactionConflict)) + } + S.LogErr(c, op, err) + return c.JSON(http.StatusInternalServerError, map[string]string{"message": msg.ErrInternalServerError}) + } + + row, err := S.Queries.GetTransactionByID(ctx, queries.GetTransactionByIDParams{ + ID: txn.ID, + UserID: user.ID, + Lang: lang, + }) + if err != nil { + S.LogErr(c, op, err) + return c.JSON(http.StatusInternalServerError, map[string]string{"message": msg.ErrInternalServerError}) + } + + return c.JSON(http.StatusOK, map[string]any{ + "message": msg.MsgTransactionUpdated, + "transaction": row, + }) +} + func (S *Server) DeleteTransactionHandler(c echo.Context) error { ctx := c.Request().Context() const op = "DeleteTransaction" diff --git a/internal/handlers/transaction_encryption.go b/internal/handlers/transaction_encryption.go index 5231082..f4607ec 100644 --- a/internal/handlers/transaction_encryption.go +++ b/internal/handlers/transaction_encryption.go @@ -77,3 +77,10 @@ func stringPtrIfNotEmpty(s string) *string { } return &s } + +func plaintextPtrUnlessEncrypted(value string, encrypted *string) *string { + if encrypted != nil { + return nil + } + return utils.StringToPointer(value) +} diff --git a/internal/handlers/user_context.go b/internal/handlers/user_context.go index 02216d3..f794d6c 100644 --- a/internal/handlers/user_context.go +++ b/internal/handlers/user_context.go @@ -102,13 +102,33 @@ func (S *Server) UpdateUserContextHandler(c echo.Context) error { summary, err = S.summarizeUserContext(ctx, geminiClient, promptDb, req.Context) if err != nil { S.LogErr(c, op, err) + if errors.Is(err, clients.ErrGeminiTemporarilyUnavailable) { + return c.JSON(http.StatusServiceUnavailable, msgResponse(msg.ErrSystemBusy)) + } return c.JSON(http.StatusInternalServerError, msgResponse(msg.ErrInternalServerError)) } } + var encryptedContext *string + var encryptedSummary *string + if user.HasEncryptionKeys && user.PublicKey != nil { + if userPubKey, err := base64DecodeKey(*user.PublicKey); err == nil && len(userPubKey) == 32 { + if req.Context != "" { + if enc, err := utils.EncryptForUser(userPubKey, []byte(req.Context)); err == nil { + encryptedContext = &enc + } + } + if summary != "" { + if enc, err := utils.EncryptForUser(userPubKey, []byte(summary)); err == nil { + encryptedSummary = &enc + } + } + } + } + updatedUser, err := S.Queries.UpdateUserContext(ctx, queries.UpdateUserContextParams{ ID: user.ID, - Context: utils.StringToPointer(req.Context), + Context: nil, ContextSummary: utils.StringToPointer(summary), }) if err != nil { @@ -116,6 +136,22 @@ func (S *Server) UpdateUserContextHandler(c echo.Context) error { return c.JSON(http.StatusInternalServerError, msgResponse(msg.ErrInternalServerError)) } + if _, err := S.DB.Exec( + ctx, + `UPDATE users + SET context = NULL, + encrypted_context = $2, + encrypted_context_summary = $3, + updated_at = CURRENT_TIMESTAMP + WHERE id = $1 AND deleted_at IS NULL`, + user.ID, + encryptedContext, + encryptedSummary, + ); err != nil { + S.LogErr(c, op, err) + return c.JSON(http.StatusInternalServerError, msgResponse(msg.ErrInternalServerError)) + } + return c.JSON(http.StatusOK, updatedUser) } @@ -245,3 +281,35 @@ func (S *Server) UpsertContextTemplateTranslationHandler(c echo.Context) error { return c.JSON(http.StatusOK, successResponse(msg.MsgTranslationUpserted)) } + +func (S *Server) PatchUserOnboardingContextHandler(c echo.Context) error { + const op = "PatchUserOnboardingContext" + + user, err := S.getUserFromClaims(c, op) + if err != nil { + if errors.Is(err, msg.ErrMissingUserClaims) { + return claimsError(c) + } + return c.JSON(http.StatusUnauthorized, msgResponse(msg.ErrInvalidOrExpiredAccessToken)) + } + + var req models.PatchUserOnboardingContextRequest + if err := c.Bind(&req); err != nil { + return c.JSON(http.StatusBadRequest, msgResponse(msg.ErrInvalidReqPayload)) + } + if err := S.Validate.Struct(req); err != nil { + return c.JSON(http.StatusBadRequest, msgResponse(msg.ErrInvalidReqPayload)) + } + + if err := S.Queries.UpdateUserOnboardingContext(c.Request().Context(), queries.UpdateUserOnboardingContextParams{ + ID: user.ID, + FinancialGoal: req.FinancialGoal, + MainChallenge: req.MainChallenge, + ExperienceLevel: req.ExperienceLevel, + }); err != nil { + S.LogErr(c, op, err) + return c.JSON(http.StatusInternalServerError, msgResponse(msg.ErrInternalServerError)) + } + + return c.JSON(http.StatusOK, successResponse(msg.MsgUpdated)) +} diff --git a/internal/handlers/voice.go b/internal/handlers/voice.go index c03a667..e6e90c7 100644 --- a/internal/handlers/voice.go +++ b/internal/handlers/voice.go @@ -6,20 +6,11 @@ import ( "errors" "fmt" "io" - "math" "net/http" "numex-api/internal/db/queries" - "numex-api/internal/models" "numex-api/internal/msg" - "numex-api/internal/utils" - "strconv" - "time" - "github.com/google/uuid" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" "github.com/labstack/echo/v4" - "google.golang.org/genai" ) func (S *Server) VoiceTransactionHandler(c echo.Context) error { @@ -53,13 +44,12 @@ func (S *Server) VoiceTransactionHandler(c echo.Context) error { // ── Idempotency check ──────────────────────────────────────────────────── if idempotencyKey != "" { - existing, err := S.Queries.GetIdempotencyKey(ctx, queries.GetIdempotencyKeyParams{ + _, err := S.Queries.GetIdempotencyKey(ctx, queries.GetIdempotencyKeyParams{ UserID: user.ID, Key: idempotencyKey, }) if err == nil { - // Key found and not expired — idempotent response (body not cached, privacy-first) - return c.JSON(int(existing.ResponseCode), map[string]string{"message": msg.MsgAlreadyProcessed}) + return c.JSON(http.StatusConflict, errResponse(msg.ErrDuplicateRequest, msg.CodeDuplicateRequest)) } } @@ -142,517 +132,12 @@ func (S *Server) VoiceTransactionHandler(c echo.Context) error { return c.JSON(http.StatusBadRequest, errResponse(msg.ErrUnsupportedAudioFormat, msg.CodeUnsupportedAudioFormat)) } - startTime := time.Now() - voiceLang := utils.LangPrefix(user.UiLanguage) - - categories, err := S.Queries.GetCategoriesByUserID(ctx, queries.GetCategoriesByUserIDParams{ - UserID: user.ID, - Lang: voiceLang, - }) - if err != nil { - S.LogErr(c, op, err) - return c.JSON(http.StatusInternalServerError, map[string]string{"message": msg.ErrInternalServerError}) - } - - balances, err := S.Queries.GetBalancesByUserID(ctx, queries.GetBalancesByUserIDParams{ - UserID: user.ID, - Lang: voiceLang, - }) + statusCode, respBody, err := S.processVoiceTransaction(ctx, user, currency, timezone, idempotencyKey, mimeType, audioBytes) if err != nil { S.LogErr(c, op, err) - return c.JSON(http.StatusInternalServerError, map[string]string{"message": msg.ErrInternalServerError}) - } - - currencies, err := S.Queries.GetActiveCurrencies(ctx) - if err != nil { - S.LogErr(c, op, err) - return c.JSON(http.StatusInternalServerError, map[string]string{"message": msg.ErrInternalServerError}) - } - - minorUnit := 0 - for _, cur := range currencies { - if cur.Code == currency { - minorUnit = int(cur.MinorUnit) - break - } - } - - catList := make([]map[string]string, len(categories)) - catIDSet := make(map[string]bool) - for i, cat := range categories { - idStr := fmt.Sprintf("%x-%x-%x-%x-%x", cat.ID.Bytes[0:4], cat.ID.Bytes[4:6], cat.ID.Bytes[6:8], cat.ID.Bytes[8:10], cat.ID.Bytes[10:16]) - catList[i] = map[string]string{"id": idStr, "name": cat.DisplayTitle} - catIDSet[idStr] = true - } - - // Fallback category: "Debts" first, "Other" second, first system category last resort. - var fallbackCatID pgtype.UUID - for _, cat := range categories { - if !cat.UserID.Valid { // system category (user_id IS NULL) - if cat.Title == "Debts" { - fallbackCatID = pgtype.UUID{Bytes: cat.ID.Bytes, Valid: true} - break - } - if cat.Title == "Other" && !fallbackCatID.Valid { - fallbackCatID = pgtype.UUID{Bytes: cat.ID.Bytes, Valid: true} - } - } - } - if !fallbackCatID.Valid && len(categories) > 0 { - fallbackCatID = pgtype.UUID{Bytes: categories[0].ID.Bytes, Valid: true} - } - - balList := make([]map[string]string, len(balances)) - balIDSet := make(map[string]bool) - for i, bal := range balances { - idStr := fmt.Sprintf("%x-%x-%x-%x-%x", bal.ID.Bytes[0:4], bal.ID.Bytes[4:6], bal.ID.Bytes[6:8], bal.ID.Bytes[8:10], bal.ID.Bytes[10:16]) - balList[i] = map[string]string{"id": idStr, "name": bal.DisplayName, "currency": bal.Currency} - balIDSet[idStr] = true - } - - // Default balance fallback — used when Gemini returns no balance match. - var defaultBalID pgtype.UUID - for _, bal := range balances { - if bal.Name == "Default" || bal.DisplayName == "Default" { - defaultBalID = pgtype.UUID{Bytes: bal.ID.Bytes, Valid: true} - break - } - } - - curList := make([]map[string]any, len(currencies)) - for i, cur := range currencies { - curList[i] = map[string]any{"code": cur.Code, "minor_unit": cur.MinorUnit} - } - - catJSON, _ := json.Marshal(catList) - balJSON, _ := json.Marshal(balList) - curJSON, _ := json.Marshal(curList) - - userPrompt := buildVoiceUserPrompt(voicePromptParams{ - lang: voiceLang, - currency: currency, - minorUnit: minorUnit, - timezone: timezone, - currentTime: time.Now().UTC().Format(time.RFC3339), - catJSON: string(catJSON), - balJSON: string(balJSON), - curJSON: string(curJSON), - }) - - // Inject open debts context for repayment linking - openDebtsCtx, _ := S.Queries.GetOpenDebtsByUserID(ctx, user.ID) - if len(openDebtsCtx) > 0 { - openDebtsJSON, _ := json.Marshal(openDebtsCtx) - userPrompt += fmt.Sprintf("\n\n\n%s\n", string(openDebtsJSON)) - } - - // Inject user context summary (all users) - if user.ContextSummary != nil && *user.ContextSummary != "" { - userPrompt += "\n\n\n" + *user.ContextSummary + "\n" - } - - // Inject recent merchants (pro users only) - if entitlement.PlanID == "pro" { - recentMerchants, err := S.Queries.GetRecentMerchantsByUserID(ctx, - queries.GetRecentMerchantsByUserIDParams{ - UserID: user.ID, - Lang: voiceLang, - }, - ) - if err == nil && len(recentMerchants) > 0 { - merchantList := make([]map[string]any, len(recentMerchants)) - for i, m := range recentMerchants { - var merchantName string - if m.Merchant != nil { - merchantName = *m.Merchant - } - merchantList[i] = map[string]any{ - "merchant": merchantName, - "category": m.CategoryName, - "count": m.Frequency, - } - } - merchantsJSON, _ := json.Marshal(merchantList) - userPrompt += "\n\n\n" + string(merchantsJSON) + "\n" - } - } - - client, err := S.Gemini.CreateClient(ctx) - if err != nil { - S.LogErr(c, op, err) - return c.JSON(http.StatusInternalServerError, errResponse(msg.ErrInternalServerError, "-1")) - } - - promptDb, err := S.Queries.GetActivePromptByName(ctx, "transaction_parse") - if err != nil { - S.LogErr(c, op, err) - return c.JSON(http.StatusInternalServerError, errResponse(msg.ErrInternalServerError, "-1")) - } - - if promptDb.SystemPrompt == "" { - promptDb.SystemPrompt = geminiSystemInstruction - } - - cfg := client.BuildGeminiConfig(promptDb, "application/json") - - audioPart := genai.NewPartFromBytes(audioBytes, mimeType) - textPart := genai.NewPartFromText(userPrompt) - - resp, err := client.GenerateMultimodal(ctx, promptDb.Model, []*genai.Part{audioPart, textPart}, cfg) - if err != nil { - S.LogErr(c, op, err) - - ms := time.Since(startTime).Milliseconds() - if ms > math.MaxInt32 { - ms = math.MaxInt32 - } - latencyMs := int32(ms) - _, _ = S.Queries.CreateParseAttempt(ctx, queries.CreateParseAttemptParams{ - UserID: user.ID, - Language: voiceLang, - Status: "failed", - LatencyMs: &latencyMs, - }) - - return c.JSON(http.StatusInternalServerError, errResponse(msg.ErrParseTransactionFailed, msg.CodeParseTransactionFailed)) - } - - text := utils.CleanGeminiResponse(resp.Text()) - - var parsed models.MultiParseResponse - if err := json.Unmarshal([]byte(text), &parsed); err != nil { - if utils.LooksLikeTruncation(text) { - retryPrompt := userPrompt + "\n\nIMPORTANT: Previous response was truncated. Be concise. Truncate transcript field if needed but always produce complete valid JSON." - retryResp, retryErr := client.GenerateMultimodal(ctx, promptDb.Model, []*genai.Part{audioPart, genai.NewPartFromText(retryPrompt)}, cfg) - if retryErr == nil { - retryText := utils.CleanGeminiResponse(retryResp.Text()) - if jsonErr := json.Unmarshal([]byte(retryText), &parsed); jsonErr == nil { - goto parsedOK - } - } - } - S.LogErr(c, op, fmt.Errorf("gemini voice response parse error: %w, response: %s", err, text)) - - ms := time.Since(startTime).Milliseconds() - if ms > math.MaxInt32 { - ms = math.MaxInt32 - } - latencyMs := int32(ms) - _, _ = S.Queries.CreateParseAttempt(ctx, queries.CreateParseAttemptParams{ - UserID: user.ID, - Language: voiceLang, - Status: "failed", - LatencyMs: &latencyMs, - }) - - return c.JSON(http.StatusUnprocessableEntity, errResponse(msg.ErrAudioParseFailed, msg.CodeAudioParseFailed)) - } -parsedOK: - - totalConfidence := 0.0 - for i := range parsed.Transactions { - parsed.Transactions[i].RawQuery = "[Voice Input]" - totalConfidence += parsed.Transactions[i].Confidence - - if parsed.Transactions[i].CategoryID != nil && !catIDSet[*parsed.Transactions[i].CategoryID] { - parsed.Transactions[i].CategoryID = nil - } - - if parsed.Transactions[i].BalanceID != nil { - if !balIDSet[*parsed.Transactions[i].BalanceID] { - parsed.Transactions[i].BalanceID = nil - } else { - for _, bal := range balances { - idStr := fmt.Sprintf("%x-%x-%x-%x-%x", bal.ID.Bytes[0:4], bal.ID.Bytes[4:6], bal.ID.Bytes[6:8], bal.ID.Bytes[8:10], bal.ID.Bytes[10:16]) - if idStr == *parsed.Transactions[i].BalanceID && bal.Currency != parsed.Transactions[i].Currency { - parsed.Transactions[i].BalanceID = nil - break - } - } - } - } - } - - avgConfidence := float32(0.0) - if len(parsed.Transactions) > 0 { - avgConfidence = float32(totalConfidence / float64(len(parsed.Transactions))) - } - - ms := time.Since(startTime).Milliseconds() - if ms > math.MaxInt32 { - ms = math.MaxInt32 + return c.JSON(http.StatusInternalServerError, errResponse(msg.ErrInternalServerError, msg.CodeInternalError)) } - latencyMs := int32(ms) - resultJSON, _ := json.Marshal(parsed) - parseStatus := "complete" - if avgConfidence < 0.7 { - parseStatus = "partial" - } - - _, _ = S.Queries.CreateParseAttempt(ctx, queries.CreateParseAttemptParams{ - UserID: user.ID, - Language: voiceLang, - Status: parseStatus, - Confidence: &avgConfidence, - LatencyMs: &latencyMs, - Result: resultJSON, - }) - - if len(parsed.Transactions) == 0 && len(parsed.Debts) == 0 { - return c.JSON(http.StatusUnprocessableEntity, errResponse(msg.ErrParseTransactionFailed, msg.CodeParseTransactionFailed)) - } - - // ── Confidence gate — reject low-confidence parses ─────────────────────── - confidenceThreshold := float32(0.7) - if cached := S.ConfigCache.GetString("confidence_threshold", ""); cached != "" { - if v, err := strconv.ParseFloat(cached, 32); err == nil { - confidenceThreshold = float32(v) - } - } - - // ── Per-transaction confidence filter ──────────────────────────────────── - var confidentTxns []models.ParseTransactionResponse - skippedCount := 0 - for _, txn := range parsed.Transactions { - if txn.Confidence >= float64(confidenceThreshold) { - confidentTxns = append(confidentTxns, txn) - } else { - skippedCount++ - } - } - parsed.Transactions = confidentTxns - - if len(parsed.Transactions) == 0 && len(parsed.Debts) == 0 { - return c.JSON(http.StatusOK, map[string]any{ - "status": "low_confidence", - "parsed": parsed, - "confidence": avgConfidence, - "transcript": parsed.Transcript, - "message": msg.MsgLowConfidenceParse, - "code": msg.CodeLowConfidenceParse, - }) - } - - if voiceTransactionLimitExceeded(transactionsLimit, dailyTransactionsUsed, parsed.Transactions) { - return c.JSON(http.StatusTooManyRequests, errResponse( - msg.ErrVoiceLimitReached, msg.CodeVoiceLimitReached, - )) - } - - // ── Fetch user public key for encryption ───────────────────────────────── - // If user has encryption keys, encrypt sensitive fields before storage. - // If not (migration case), store plaintext temporarily — migration job encrypts later. - var userPubKey []byte - if user.HasEncryptionKeys && user.PublicKey != nil { - // PublicKey is stored as base64 — decode it for EncryptForUser - decoded, err := base64DecodeKey(*user.PublicKey) - if err == nil && len(decoded) == 32 { - userPubKey = decoded - } - } - if len(parsed.Transactions) > 0 && len(userPubKey) != 32 { - return c.JSON(http.StatusConflict, msgResponse(msg.ErrEncryptionKeysRequired)) - } - - // ── Create transactions in DB ──────────────────────────────────────────── - - batchUUID := uuid.New() - batchID := pgtype.UUID{Bytes: batchUUID, Valid: true} - - enriched := make([]queries.GetTransactionByIDRow, 0, len(parsed.Transactions)) - - for _, pt := range parsed.Transactions { - // Category - var catID pgtype.UUID - if pt.CategoryID != nil { - parsed, err := uuid.Parse(*pt.CategoryID) - if err == nil { - catID = pgtype.UUID{Bytes: parsed, Valid: true} - } - } - if !catID.Valid { - if fallbackCatID.Valid { - catID = fallbackCatID - } else { - continue - } - } - - // Balance - var balID pgtype.UUID - if pt.BalanceID != nil { - if parsed, err := uuid.Parse(*pt.BalanceID); err == nil { - balID = pgtype.UUID{Bytes: parsed, Valid: true} - } - } - if !balID.Valid && defaultBalID.Valid { - balID = defaultBalID - } - - // OccurredAt - occurredAt := time.Now().UTC() - if pt.OccurredAt != "" { - if t, err := time.Parse(time.RFC3339, pt.OccurredAt); err == nil { - occurredAt = t - } - } - - rawQuery := pt.RawQuery - - // Encrypt sensitive fields if user has keys (no-log zone: never log these values) - var encAmount, encMerchant, encNote, encRawQuery *string - if enc, err := utils.EncryptForUser(userPubKey, []byte(fmt.Sprintf("%d", pt.AmountMinor))); err == nil { - encAmount = &enc - } - if pt.Merchant != nil { - if enc, err := utils.EncryptForUser(userPubKey, []byte(*pt.Merchant)); err == nil { - encMerchant = &enc - } - } - if rawQuery != "" { - if enc, err := utils.EncryptForUser(userPubKey, []byte(rawQuery)); err == nil { - encRawQuery = &enc - } - } - - txn, err := S.Queries.CreateTransactionWithBatch(ctx, queries.CreateTransactionWithBatchParams{ - UserID: user.ID, - CategoryID: catID, - Type: pt.Type, - Currency: pt.Currency, - Merchant: pt.Merchant, - Note: nil, - RawQuery: nil, - EncryptedAmount: encAmount, - EncryptedMerchant: encMerchant, - EncryptedNote: encNote, - EncryptedRawQuery: encRawQuery, - OccurredAt: pgtype.Timestamptz{Time: occurredAt, Valid: true}, - Source: "voice", - BalanceID: balID, - BatchID: batchID, - }) - if err != nil { - S.LogErr(c, op, fmt.Errorf("create voice transaction: %w", err)) - continue - } - - row, err := S.Queries.GetTransactionByID(ctx, queries.GetTransactionByIDParams{ - ID: txn.ID, - UserID: user.ID, - Lang: voiceLang, - }) - if err != nil { - if !errors.Is(err, pgx.ErrNoRows) { - S.LogErr(c, op, fmt.Errorf("fetch voice transaction: %w", err)) - } - continue - } - - enriched = append(enriched, row) - } - - if len(enriched) == 0 && len(parsed.Debts) == 0 { - return c.JSON(http.StatusInternalServerError, errResponse(msg.ErrParseTransactionFailed, msg.CodeParseTransactionFailed)) - } - - // ── Create debts from Gemini response ──────────────────────────────────── - createdDebts := make([]models.DebtResponse, 0, len(parsed.Debts)) - var lowConfidenceDebts []models.GeminiDebtItem - - for _, gd := range parsed.Debts { - if gd.Confidence < 0.7 { - lowConfidenceDebts = append(lowConfidenceDebts, gd) - continue - } - if len(gd.Counterparty) > 120 || len(gd.Currency) != 3 { - continue - } - - var encCounterparty, encNote *string - if len(userPubKey) == 32 { - if enc, err := utils.EncryptForUser(userPubKey, []byte(gd.Counterparty)); err == nil { - encCounterparty = &enc - } - if gd.Note != "" { - if enc, err := utils.EncryptForUser(userPubKey, []byte(gd.Note)); err == nil { - encNote = &enc - } - } - } - - var notePtr *string - if gd.Note != "" { - notePtr = &gd.Note - } - - debt, err := S.Queries.CreateDebt(ctx, queries.CreateDebtParams{ - UserID: user.ID, - Counterparty: gd.Counterparty, - EncryptedCounterparty: encCounterparty, - Direction: gd.Direction, - AmountMinorOriginal: gd.AmountMinor, - Currency: gd.Currency, - Note: notePtr, - EncryptedNote: encNote, - Source: "voice", - }) - if err != nil { - S.LogErr(c, op, fmt.Errorf("create voice debt: %w", err)) - continue - } - createdDebts = append(createdDebts, debtToResponse(debt, nil)) - } - - // ── Link debt_transactions (repayments) ────────────────────────────────── - for _, link := range parsed.DebtTransactions { - if link.Confidence < 0.7 || link.TransactionIndex >= len(enriched) { - continue - } - txn := enriched[link.TransactionIndex] - - openDebts, err := S.Queries.GetOpenDebtsByUserID(ctx, user.ID) - if err != nil { - continue - } - for _, od := range openDebts { - if od.Counterparty == link.DebtCounterparty { - debtUUID := pgtype.UUID{Bytes: od.ID.Bytes, Valid: true} - _ = S.Queries.SetTransactionDebtID(ctx, queries.SetTransactionDebtIDParams{ - ID: txn.ID, - DebtID: debtUUID, - UserID: user.ID, - }) - if err := S.recalcDebtRemaining(ctx, debtUUID, user.ID); err != nil { - S.LogErr(c, op, fmt.Errorf("recalc debt %s: %w", od.ID, err)) - } - break - } - } - } - - respBody, _ := json.Marshal(map[string]any{ - "transactions": enriched, - "debts": createdDebts, - "language": parsed.Language, - "raw_transcript": parsed.Transcript, - "low_confidence_debts": lowConfidenceDebts, - "skipped_transactions": skippedCount, - }) - - // ── Store idempotency key ──────────────────────────────────────────────── - if idempotencyKey != "" { - _ = S.Queries.CreateIdempotencyKey(ctx, queries.CreateIdempotencyKeyParams{ - UserID: user.ID, - Key: idempotencyKey, - RequestPath: "/api/transactions/voice", - RequestHash: idempotencyKey, // key itself is unique enough - ResponseCode: http.StatusOK, - ExpiresAt: pgtype.Timestamptz{Time: time.Now().Add(24 * time.Hour), Valid: true}, - }) - } - - return c.JSONBlob(http.StatusOK, respBody) + return c.JSONBlob(statusCode, respBody) } // base64DecodeKey decodes a base64-encoded 32-byte X25519 public key. diff --git a/internal/handlers/voice_process.go b/internal/handlers/voice_process.go new file mode 100644 index 0000000..c725fe3 --- /dev/null +++ b/internal/handlers/voice_process.go @@ -0,0 +1,460 @@ +package handlers + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "math" + "net/http" + "strconv" + "time" + + "numex-api/internal/clients" + "numex-api/internal/db/queries" + "numex-api/internal/models" + "numex-api/internal/msg" + "numex-api/internal/utils" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + "google.golang.org/genai" +) + +func (S *Server) processVoiceTransaction(ctx context.Context, user queries.User, currency, timezone, idempotencyKey, mimeType string, audioBytes []byte) (int, []byte, error) { + startTime := time.Now() + voiceLang := utils.LangPrefix(user.UiLanguage) + + categories, err := S.Queries.GetCategoriesByUserID(ctx, queries.GetCategoriesByUserIDParams{ + UserID: user.ID, + Lang: voiceLang, + }) + if err != nil { + return http.StatusInternalServerError, nil, err + } + + balances, err := S.Queries.GetBalancesByUserID(ctx, queries.GetBalancesByUserIDParams{ + UserID: user.ID, + Lang: voiceLang, + }) + if err != nil { + return http.StatusInternalServerError, nil, err + } + + currencies, err := S.Queries.GetActiveCurrencies(ctx) + if err != nil { + return http.StatusInternalServerError, nil, err + } + + entitlement, _ := S.Queries.GetEntitlementByUserID(ctx, user.ID) + transactionsLimit := -1 + if len(entitlement.Features) > 0 { + var features map[string]any + if json.Unmarshal(entitlement.Features, &features) == nil { + _, transactionsLimit = getVoiceQuotaLimits(features) + } + } + dailyTransactionsUsed := 0 + if transactionsLimit != -1 { + dailyCount, err := S.Queries.CountDailyVoiceTransactionsByUserID(ctx, user.ID) + if err != nil { + return http.StatusInternalServerError, nil, err + } + dailyTransactionsUsed = int(dailyCount) + } + + minorUnit := 0 + for _, cur := range currencies { + if cur.Code == currency { + minorUnit = int(cur.MinorUnit) + break + } + } + + catList := make([]map[string]string, len(categories)) + catIDSet := make(map[string]bool) + for i, cat := range categories { + idStr := fmt.Sprintf("%x-%x-%x-%x-%x", cat.ID.Bytes[0:4], cat.ID.Bytes[4:6], cat.ID.Bytes[6:8], cat.ID.Bytes[8:10], cat.ID.Bytes[10:16]) + catList[i] = map[string]string{ + "id": idStr, + "name": cat.DisplayTitle, + "canonical_name": cat.Title, + } + catIDSet[idStr] = true + } + + fallbackCatID := fallbackVoiceCategoryID(categories) + + balList := make([]map[string]string, len(balances)) + balIDSet := make(map[string]bool) + for i, bal := range balances { + idStr := fmt.Sprintf("%x-%x-%x-%x-%x", bal.ID.Bytes[0:4], bal.ID.Bytes[4:6], bal.ID.Bytes[6:8], bal.ID.Bytes[8:10], bal.ID.Bytes[10:16]) + balList[i] = map[string]string{"id": idStr, "name": bal.DisplayName, "currency": bal.Currency} + balIDSet[idStr] = true + } + + var defaultBalID pgtype.UUID + for _, bal := range balances { + if bal.Name == "Default" || bal.DisplayName == "Default" { + defaultBalID = pgtype.UUID{Bytes: bal.ID.Bytes, Valid: true} + break + } + } + + curList := make([]map[string]any, len(currencies)) + for i, cur := range currencies { + curList[i] = map[string]any{"code": cur.Code, "minor_unit": cur.MinorUnit} + } + + catJSON, _ := json.Marshal(catList) + balJSON, _ := json.Marshal(balList) + curJSON, _ := json.Marshal(curList) + + userPrompt := buildVoiceUserPrompt(voicePromptParams{ + lang: voiceLang, + currency: currency, + minorUnit: minorUnit, + timezone: timezone, + currentTime: time.Now().UTC().Format(time.RFC3339), + catJSON: string(catJSON), + balJSON: string(balJSON), + curJSON: string(curJSON), + }) + + openDebtsCtx, _ := S.Queries.GetOpenDebtsByUserID(ctx, user.ID) + recentMerchants, _ := S.Queries.GetRecentMerchantsByUserID(ctx, queries.GetRecentMerchantsByUserIDParams{UserID: user.ID, Lang: voiceLang}) + userContext := "" + if user.ContextSummary != nil { + userContext = *user.ContextSummary + } + userPrompt = appendParserContext(userPrompt, openDebtsCtx, recentMerchants, userContext) + + client, err := S.Gemini.CreateClient(ctx) + if err != nil { + return http.StatusInternalServerError, nil, err + } + promptDb, err := S.Queries.GetActivePromptByName(ctx, "transaction_parse") + if err != nil { + return http.StatusInternalServerError, nil, err + } + if promptDb.SystemPrompt == "" { + return http.StatusInternalServerError, nil, fmt.Errorf("empty transaction_parse prompt") + } + cfg := client.BuildGeminiConfig(promptDb, "application/json") + audioPart := genai.NewPartFromBytes(audioBytes, mimeType) + textPart := genai.NewPartFromText(userPrompt) + + resp, err := client.GenerateMultimodal(ctx, promptDb.Model, []*genai.Part{audioPart, textPart}, cfg) + if err != nil { + ms := time.Since(startTime).Milliseconds() + if ms > math.MaxInt32 { + ms = math.MaxInt32 + } + latencyMs := int32(ms) + _, _ = S.Queries.CreateParseAttempt(ctx, queries.CreateParseAttemptParams{ + UserID: user.ID, Language: voiceLang, Status: "failed", LatencyMs: &latencyMs, + }) + if errors.Is(err, clients.ErrGeminiTemporarilyUnavailable) { + body, _ := json.Marshal(errResponse(msg.ErrSystemBusy, msg.CodeSystemBusy)) + return http.StatusServiceUnavailable, body, nil + } + body, _ := json.Marshal(errResponse(msg.ErrParseTransactionFailed, msg.CodeParseTransactionFailed)) + return http.StatusInternalServerError, body, nil + } + + text := utils.CleanGeminiResponse(resp.Text()) + var parsed models.MultiParseResponse + if err := json.Unmarshal([]byte(text), &parsed); err != nil { + if utils.LooksLikeTruncation(text) { + retryPrompt := userPrompt + "\n\nIMPORTANT: Previous response was truncated. Be concise. Truncate transcript field if needed but always produce complete valid JSON." + retryResp, retryErr := client.GenerateMultimodal(ctx, promptDb.Model, []*genai.Part{audioPart, genai.NewPartFromText(retryPrompt)}, cfg) + if retryErr == nil { + retryText := utils.CleanGeminiResponse(retryResp.Text()) + if jsonErr := json.Unmarshal([]byte(retryText), &parsed); jsonErr == nil { + goto parsedOK + } + } + } + ms := time.Since(startTime).Milliseconds() + if ms > math.MaxInt32 { + ms = math.MaxInt32 + } + latencyMs := int32(ms) + _, _ = S.Queries.CreateParseAttempt(ctx, queries.CreateParseAttemptParams{ + UserID: user.ID, Language: voiceLang, Status: "failed", LatencyMs: &latencyMs, + }) + body, _ := json.Marshal(errResponse(msg.ErrAudioParseFailed, msg.CodeAudioParseFailed)) + return http.StatusUnprocessableEntity, body, nil + } +parsedOK: + totalConfidence := 0.0 + for i := range parsed.Transactions { + parsed.Transactions[i].RawQuery = "[Voice Input]" + totalConfidence += parsed.Transactions[i].Confidence + if parsed.Transactions[i].CategoryID != nil && !catIDSet[*parsed.Transactions[i].CategoryID] { + parsed.Transactions[i].CategoryID = nil + } + if parsed.Transactions[i].BalanceID != nil { + if !balIDSet[*parsed.Transactions[i].BalanceID] { + parsed.Transactions[i].BalanceID = nil + } else { + for _, bal := range balances { + idStr := fmt.Sprintf("%x-%x-%x-%x-%x", bal.ID.Bytes[0:4], bal.ID.Bytes[4:6], bal.ID.Bytes[6:8], bal.ID.Bytes[8:10], bal.ID.Bytes[10:16]) + if idStr == *parsed.Transactions[i].BalanceID && bal.Currency != parsed.Transactions[i].Currency { + parsed.Transactions[i].BalanceID = nil + break + } + } + } + } + } + parsed.DebtBundles = validDebtBundleCandidates(parsed.DebtBundles) + if len(parsed.DebtBundles) == 0 { + parsed.DebtBundles = debtBundleCandidatesFromDebts(parsed.Debts, balances, "voice") + } + if len(parsed.DebtTransactions) > 0 { + parsed.DebtBundles = append( + parsed.DebtBundles, + debtBundleCandidatesFromRepayments(parsed.DebtTransactions, parsed.Transactions, balances)..., + ) + } + parsed.Transactions = removeDebtBundleTransactions(parsed.Transactions, parsed.DebtBundles) + avgConfidence := float32(0.0) + if len(parsed.Transactions) > 0 { + avgConfidence = float32(totalConfidence / float64(len(parsed.Transactions))) + } + ms := time.Since(startTime).Milliseconds() + if ms > math.MaxInt32 { + ms = math.MaxInt32 + } + latencyMs := int32(ms) + resultJSON, _ := json.Marshal(parsed) + parseStatus := "complete" + if avgConfidence < 0.7 { + parseStatus = "partial" + } + _, _ = S.Queries.CreateParseAttempt(ctx, queries.CreateParseAttemptParams{ + UserID: user.ID, Language: voiceLang, Status: parseStatus, Confidence: &avgConfidence, LatencyMs: &latencyMs, Result: resultJSON, + }) + + if len(parsed.Transactions) == 0 && len(parsed.Debts) == 0 && len(parsed.DebtBundles) == 0 { + body, _ := json.Marshal(errResponse(msg.ErrParseTransactionFailed, msg.CodeParseTransactionFailed)) + return http.StatusUnprocessableEntity, body, nil + } + + confidenceThreshold := float32(0.7) + if cached := S.ConfigCache.GetString("confidence_threshold", ""); cached != "" { + if v, err := strconv.ParseFloat(cached, 32); err == nil { + confidenceThreshold = float32(v) + } + } + var confidentTxns []models.ParseTransactionResponse + skippedCount := 0 + for _, txn := range parsed.Transactions { + if txn.Confidence >= float64(confidenceThreshold) { + confidentTxns = append(confidentTxns, txn) + } else { + skippedCount++ + } + } + parsed.Transactions = confidentTxns + if len(parsed.Transactions) == 0 && len(parsed.Debts) == 0 && len(parsed.DebtBundles) == 0 { + body, _ := json.Marshal(map[string]any{ + "status": "low_confidence", "parsed": parsed, "confidence": avgConfidence, "transcript": parsed.Transcript, + "message": msg.MsgLowConfidenceParse, "code": msg.CodeLowConfidenceParse, + }) + return http.StatusOK, body, nil + } + if voiceTransactionLimitExceeded(transactionsLimit, dailyTransactionsUsed, parsed.Transactions) { + body, _ := json.Marshal(errResponse(msg.ErrVoiceLimitReached, msg.CodeVoiceLimitReached)) + return http.StatusTooManyRequests, body, nil + } + + var userPubKey []byte + if user.HasEncryptionKeys && user.PublicKey != nil { + decoded, err := base64DecodeKey(*user.PublicKey) + if err == nil && len(decoded) == 32 { + userPubKey = decoded + } + } + if len(parsed.Transactions) > 0 && len(userPubKey) != 32 { + body, _ := json.Marshal(msgResponse(msg.ErrEncryptionKeysRequired)) + return http.StatusConflict, body, nil + } + + batchUUID := uuid.New() + batchID := pgtype.UUID{Bytes: batchUUID, Valid: true} + enriched := make([]queries.GetTransactionByIDRow, 0, len(parsed.Transactions)) + for _, pt := range parsed.Transactions { + var catID pgtype.UUID + if pt.CategoryID != nil { + parsedUUID, err := uuid.Parse(*pt.CategoryID) + if err == nil { + catID = pgtype.UUID{Bytes: parsedUUID, Valid: true} + } + } + if !catID.Valid { + if fallbackCatID.Valid { + catID = fallbackCatID + } else { + continue + } + } + var balID pgtype.UUID + if pt.BalanceID != nil { + if parsedUUID, err := uuid.Parse(*pt.BalanceID); err == nil { + balID = pgtype.UUID{Bytes: parsedUUID, Valid: true} + } + } + if !balID.Valid && defaultBalID.Valid { + balID = defaultBalID + } + occurredAt := time.Now().UTC() + if pt.OccurredAt != "" { + if t, err := time.Parse(time.RFC3339, pt.OccurredAt); err == nil { + occurredAt = t + } + } + rawQuery := pt.RawQuery + var encAmount, encMerchant, encNote, encRawQuery *string + if enc, err := utils.EncryptForUser(userPubKey, []byte(fmt.Sprintf("%d", pt.AmountMinor))); err == nil { + encAmount = &enc + } + if pt.Merchant != nil { + if enc, err := utils.EncryptForUser(userPubKey, []byte(*pt.Merchant)); err == nil { + encMerchant = &enc + } + } + if rawQuery != "" { + if enc, err := utils.EncryptForUser(userPubKey, []byte(rawQuery)); err == nil { + encRawQuery = &enc + } + } + txn, err := S.Queries.CreateTransactionWithBatch(ctx, queries.CreateTransactionWithBatchParams{ + UserID: user.ID, CategoryID: catID, Type: pt.Type, Currency: pt.Currency, Merchant: pt.Merchant, Note: nil, RawQuery: nil, + EncryptedAmount: encAmount, EncryptedMerchant: encMerchant, EncryptedNote: encNote, EncryptedRawQuery: encRawQuery, + OccurredAt: pgtype.Timestamptz{Time: occurredAt, Valid: true}, Source: "voice", BalanceID: balID, BatchID: batchID, + }) + if err != nil { + continue + } + row, err := S.Queries.GetTransactionByID(ctx, queries.GetTransactionByIDParams{ID: txn.ID, UserID: user.ID, Lang: voiceLang}) + if err != nil { + continue + } + enriched = append(enriched, row) + } + if len(enriched) == 0 && len(parsed.Debts) == 0 && len(parsed.DebtBundles) == 0 { + body, _ := json.Marshal(errResponse(msg.ErrParseTransactionFailed, msg.CodeParseTransactionFailed)) + return http.StatusInternalServerError, body, nil + } + + createdDebts := make([]models.DebtResponse, 0, len(parsed.Debts)) + var lowConfidenceDebts []models.GeminiDebtItem + if len(parsed.DebtBundles) == 0 { + for _, gd := range parsed.Debts { + if gd.Confidence < 0.7 { + lowConfidenceDebts = append(lowConfidenceDebts, gd) + continue + } + if len(gd.Counterparty) > 120 || len(gd.Currency) != 3 { + continue + } + var encCounterparty, encNote *string + if len(userPubKey) == 32 { + if enc, err := utils.EncryptForUser(userPubKey, []byte(gd.Counterparty)); err == nil { + encCounterparty = &enc + } + if gd.Note != "" { + if enc, err := utils.EncryptForUser(userPubKey, []byte(gd.Note)); err == nil { + encNote = &enc + } + } + } + var notePtr *string + if gd.Note != "" { + notePtr = &gd.Note + } + debt, err := S.Queries.CreateDebt(ctx, queries.CreateDebtParams{ + UserID: user.ID, Counterparty: gd.Counterparty, EncryptedCounterparty: encCounterparty, + Direction: gd.Direction, AmountMinorOriginal: gd.AmountMinor, Currency: gd.Currency, + Note: notePtr, EncryptedNote: encNote, Source: "voice", + }) + if err != nil { + continue + } + createdDebts = append(createdDebts, debtToResponse(debt, nil)) + } + } + + for _, link := range parsed.DebtTransactions { + if link.Confidence < 0.7 || link.TransactionIndex >= len(enriched) { + continue + } + txn := enriched[link.TransactionIndex] + openDebts, err := S.Queries.GetOpenDebtsByUserID(ctx, user.ID) + if err != nil { + continue + } + for _, od := range openDebts { + if od.Counterparty == link.DebtCounterparty { + debtUUID := pgtype.UUID{Bytes: od.ID.Bytes, Valid: true} + _ = S.Queries.SetTransactionDebtID(ctx, queries.SetTransactionDebtIDParams{ID: txn.ID, DebtID: debtUUID, UserID: user.ID}) + _ = S.recalcDebtRemaining(ctx, debtUUID, user.ID) + break + } + } + } + + respBody, _ := json.Marshal(map[string]any{ + "transactions": enriched, "debts": createdDebts, "language": parsed.Language, "raw_transcript": parsed.Transcript, + "debt_bundles": parsed.DebtBundles, "low_confidence_debts": lowConfidenceDebts, "skipped_transactions": skippedCount, + }) + if idempotencyKey != "" { + _ = S.Queries.CreateIdempotencyKey(ctx, queries.CreateIdempotencyKeyParams{ + UserID: user.ID, Key: idempotencyKey, RequestPath: "/api/transactions/voice", RequestHash: idempotencyKey, + ResponseCode: http.StatusOK, ExpiresAt: pgtype.Timestamptz{Time: time.Now().Add(24 * time.Hour), Valid: true}, + }) + } + return http.StatusOK, respBody, nil +} + +func fallbackVoiceCategoryID(categories []queries.GetCategoriesByUserIDRow) pgtype.UUID { + var firstNonDebt pgtype.UUID + for _, cat := range categories { + if !cat.UserID.Valid && cat.Title == "Other" { + return pgtype.UUID{Bytes: cat.ID.Bytes, Valid: true} + } + if cat.Title != "Debts" && !firstNonDebt.Valid { + firstNonDebt = pgtype.UUID{Bytes: cat.ID.Bytes, Valid: true} + } + } + return firstNonDebt +} + +func removeDebtBundleTransactions( + transactions []models.ParseTransactionResponse, + bundles []models.GeminiDebtBundleCandidate, +) []models.ParseTransactionResponse { + if len(transactions) == 0 || len(bundles) == 0 { + return transactions + } + + remaining := append([]models.ParseTransactionResponse(nil), transactions...) + for _, bundle := range bundles { + expectedType := "expense" + if (bundle.Kind == "creation" && bundle.Direction == "owed") || + (bundle.Kind == "repayment" && bundle.Direction == "lent") { + expectedType = "income" + } + for i, txn := range remaining { + if txn.AmountMinor == bundle.AmountMinor && + txn.Currency == bundle.Currency && + txn.Type == expectedType { + remaining = append(remaining[:i], remaining[i+1:]...) + break + } + } + } + return remaining +} diff --git a/internal/handlers/voice_prompt_test.go b/internal/handlers/voice_prompt_test.go index a9aff6b..1c0b02d 100644 --- a/internal/handlers/voice_prompt_test.go +++ b/internal/handlers/voice_prompt_test.go @@ -1,8 +1,17 @@ package handlers import ( + "fmt" + "os" "strings" "testing" + + "numex-api/internal/db/queries" + "numex-api/internal/models" + + "github.com/go-playground/validator/v10" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" ) func TestBuildVoiceUserPrompt_XMLStructure(t *testing.T) { @@ -30,3 +39,380 @@ func TestBuildVoiceUserPrompt_XMLStructure(t *testing.T) { t.Error("old CATEGORIES: header should be removed") } } + +func TestAppendParserContextIncludesOpenDebtsAndRecentMerchants(t *testing.T) { + prompt := appendParserContext( + "base", + []queries.GetOpenDebtsByUserIDRow{ + { + Counterparty: "Jon", + Direction: "lent", + AmountMinorRemaining: 100000, + Currency: "UZS", + }, + }, + []queries.GetRecentMerchantsByUserIDRow{ + { + Merchant: stringPtr("Korzinka"), + CategoryName: "Groceries", + Frequency: 3, + }, + }, + "context summary", + ) + + for _, want := range []string{ + "", + "Jon", + "", + "Korzinka", + "", + "context summary", + } { + if !strings.Contains(prompt, want) { + t.Fatalf("prompt missing %q: %s", want, prompt) + } + } +} + +func stringPtr(v string) *string { return &v } + +func TestAppendParserContextKeepsStableSectionOrder(t *testing.T) { + prompt := appendParserContext( + "base", + []queries.GetOpenDebtsByUserIDRow{{Counterparty: "Jon"}}, + []queries.GetRecentMerchantsByUserIDRow{{Merchant: stringPtr("Korzinka")}}, + "context summary", + ) + + openDebtsAt := strings.Index(prompt, "") + userContextAt := strings.Index(prompt, "") + recentMerchantsAt := strings.Index(prompt, "") + + if openDebtsAt >= userContextAt || userContextAt >= recentMerchantsAt { + t.Fatalf( + "unexpected section order: open_debts=%d user_context=%d recent_merchants=%d", + openDebtsAt, + userContextAt, + recentMerchantsAt, + ) + } +} + +func TestAppendParserContextUsesCompactAggregatesOnly(t *testing.T) { + prompt := appendParserContext( + "base", + nil, + []queries.GetRecentMerchantsByUserIDRow{ + { + Merchant: stringPtr("Korzinka"), + CategoryName: "Groceries", + Frequency: 3, + }, + }, + "", + ) + + for _, want := range []string{ + `"merchant":"Korzinka"`, + `"category":"Groceries"`, + `"count":3`, + } { + if !strings.Contains(prompt, want) { + t.Fatalf("prompt missing %q: %s", want, prompt) + } + } + for _, forbidden := range []string{"note", "raw_query", "amount_minor"} { + if strings.Contains(prompt, forbidden) { + t.Fatalf("prompt should not contain %q: %s", forbidden, prompt) + } + } +} + +func TestParserPromptsKeepSemanticCategoryGuidance(t *testing.T) { + seed, err := os.ReadFile("../db/data.sql") + if err != nil { + t.Fatal(err) + } + promptSeed := string(seed) + if !strings.Contains(promptSeed, "canonical_name") { + t.Fatal("expected canonical_name category guidance") + } + if !strings.Contains(promptSeed, "Do not return null when the transaction clearly") { + t.Fatal("expected clear-match category guidance") + } + if !strings.Contains(promptSeed, "do NOT emit a standalone paired transaction") { + t.Fatal("expected debt bundle single-source guidance") + } + if !strings.Contains(promptSeed, `"debt_bundles": [ ... ]`) { + t.Fatal("expected debt_bundles in seed prompt output schema") + } +} + +func TestFallbackVoiceCategoryIDPrefersOtherOverDebts(t *testing.T) { + otherID := uuid.New() + debtsID := uuid.New() + + got := fallbackVoiceCategoryID([]queries.GetCategoriesByUserIDRow{ + { + ID: pgtype.UUID{Bytes: debtsID, Valid: true}, + Title: "Debts", + }, + { + ID: pgtype.UUID{Bytes: otherID, Valid: true}, + Title: "Other", + }, + }) + + if got.Bytes != otherID { + t.Fatalf("fallback = %s, want Other %s", uuid.UUID(got.Bytes), otherID) + } +} + +func TestFallbackVoiceCategoryIDNeverFallsBackToDebts(t *testing.T) { + debtsID := uuid.New() + + got := fallbackVoiceCategoryID([]queries.GetCategoriesByUserIDRow{ + { + ID: pgtype.UUID{Bytes: debtsID, Valid: true}, + Title: "Debts", + }, + }) + + if got.Valid { + t.Fatalf("fallback = %s, want invalid when only Debts exists", uuid.UUID(got.Bytes)) + } +} + +func TestDebtBundleCandidatesFromDebtsMapsHighConfidenceDebt(t *testing.T) { + balanceID := uuid.MustParse("00000000-0000-0000-0000-000000000123") + + got := debtBundleCandidatesFromDebts([]models.GeminiDebtItem{ + { + Counterparty: "Ali", + Direction: "lent", + AmountMinor: 120000, + Currency: "USD", + Note: "lunch", + Confidence: 0.92, + }, + }, []queries.GetBalancesByUserIDRow{ + { + ID: pgtype.UUID{Bytes: balanceID, Valid: true}, + Name: "Default", + DisplayName: "Default", + Currency: "USD", + }, + }, "voice") + + if len(got) != 1 { + t.Fatalf("candidates len = %d, want 1", len(got)) + } + if got[0].Kind != "creation" || got[0].Source != "voice" || got[0].Direction != "lent" { + t.Fatalf("candidate = %+v, want creation voice lent", got[0]) + } + if got[0].IncludeInAnalytics { + t.Fatal("expected candidate to opt out of analytics") + } + if len(got[0].Splits) != 1 || got[0].Splits[0].BalanceID != balanceID.String() { + t.Fatalf("split = %+v, want default balance %s", got[0].Splits, balanceID) + } +} + +func TestDebtBundleCandidatesFromDebtsOmitsBalanceWhenNoCurrencyMatch(t *testing.T) { + got := debtBundleCandidatesFromDebts([]models.GeminiDebtItem{ + { + Counterparty: "Ali", + Direction: "owed", + AmountMinor: 50000, + Currency: "USD", + Confidence: 0.91, + }, + }, []queries.GetBalancesByUserIDRow{ + { + ID: pgtype.UUID{Bytes: uuid.New(), Valid: true}, + Name: "Default", + DisplayName: "Default", + Currency: "UZS", + }, + }, "chat_manual") + + if len(got) != 1 { + t.Fatalf("candidates len = %d, want 1", len(got)) + } + if got[0].Splits[0].BalanceID != "" { + t.Fatalf("balance_id = %q, want empty when no matching balance", got[0].Splits[0].BalanceID) + } +} + +func TestDebtBundleCandidatesFromDebtsOmitsBalanceWhenCurrencyAmbiguous(t *testing.T) { + got := debtBundleCandidatesFromDebts([]models.GeminiDebtItem{ + { + Counterparty: "Ali", + Direction: "owed", + AmountMinor: 50000, + Currency: "USD", + Confidence: 0.91, + }, + }, []queries.GetBalancesByUserIDRow{ + {ID: pgtype.UUID{Bytes: uuid.New(), Valid: true}, Currency: "USD"}, + {ID: pgtype.UUID{Bytes: uuid.New(), Valid: true}, Currency: "USD"}, + }, "chat_manual") + + if len(got) != 1 { + t.Fatalf("candidates len = %d, want 1", len(got)) + } + if got[0].Splits[0].BalanceID != "" { + t.Fatalf("balance_id = %q, want app-side balance confirmation", got[0].Splits[0].BalanceID) + } +} + +func TestCreateBalanceRequestAllowsZeroInitialAmount(t *testing.T) { + req := models.CreateBalanceRequest{ + Name: "Cash", + Currency: "UZS", + InitialAmountMinor: 0, + ColorToken: "blue_500", + } + if err := validator.New().Struct(req); err != nil { + t.Fatalf("CreateBalanceRequest zero initial amount validation error = %v", err) + } +} + +func TestValidDebtBundleCandidatesDropsMalformedAIBundle(t *testing.T) { + got := validDebtBundleCandidates([]models.GeminiDebtBundleCandidate{ + { + Counterparty: "Kamron", + Direction: "lent", + AmountMinor: 50000, + Currency: "UZS", + ImpactAmountMinor: 0, + ImpactCurrency: "", + IncludeInAnalytics: false, + Confidence: 1, + }, + }) + + if len(got) != 0 { + t.Fatalf("candidates len = %d, want malformed AI bundle dropped", len(got)) + } +} + +func TestValidDebtBundleCandidatesKeepsCompleteCreationBundle(t *testing.T) { + got := validDebtBundleCandidates([]models.GeminiDebtBundleCandidate{ + { + Kind: "creation", + Source: "voice", + Counterparty: "Kamron", + Direction: "lent", + AmountMinor: 50000, + Currency: "UZS", + ImpactAmountMinor: 50000, + ImpactCurrency: "UZS", + Splits: []models.DebtBundleSplitRequest{ + {AmountMinor: 50000, Currency: "UZS"}, + }, + Confidence: 1, + }, + }) + + if len(got) != 1 { + t.Fatalf("candidates len = %d, want complete bundle kept", len(got)) + } +} + +func TestRemoveDebtBundleTransactionsDropsMatchingLegacyPair(t *testing.T) { + got := removeDebtBundleTransactions( + []models.ParseTransactionResponse{ + { + AmountMinor: 100000, + Currency: "UZS", + Type: "expense", + }, + }, + []models.GeminiDebtBundleCandidate{ + { + Kind: "creation", + Direction: "lent", + AmountMinor: 100000, + Currency: "UZS", + }, + }, + ) + + if len(got) != 0 { + t.Fatalf("transactions len = %d, want 0", len(got)) + } +} + +func TestRemoveDebtBundleTransactionsKeepsUnrelatedTransaction(t *testing.T) { + got := removeDebtBundleTransactions( + []models.ParseTransactionResponse{ + { + AmountMinor: 50000, + Currency: "UZS", + Type: "expense", + }, + { + AmountMinor: 100000, + Currency: "UZS", + Type: "expense", + }, + }, + []models.GeminiDebtBundleCandidate{ + { + Kind: "creation", + Direction: "lent", + AmountMinor: 100000, + Currency: "UZS", + }, + }, + ) + + if len(got) != 1 || got[0].AmountMinor != 50000 { + t.Fatalf("transactions = %+v, want only unrelated transaction", got) + } +} + +func TestDebtBundleCandidatesFromRepaymentsDoesNotUsePlaintextDebt(t *testing.T) { + balanceID := uuid.New() + balanceIDStr := fmt.Sprintf("%x-%x-%x-%x-%x", balanceID[0:4], balanceID[4:6], balanceID[6:8], balanceID[8:10], balanceID[10:16]) + + got := debtBundleCandidatesFromRepayments( + []models.GeminiDebtTransactionLink{ + { + DebtCounterparty: "Kamron", + TransactionIndex: 0, + Confidence: 1, + }, + }, + []models.ParseTransactionResponse{ + { + AmountMinor: 20000, + Currency: "UZS", + Type: "income", + }, + }, + []queries.GetBalancesByUserIDRow{ + { + ID: pgtype.UUID{Bytes: balanceID, Valid: true}, + Name: "Default", + DisplayName: "Default", + Currency: "UZS", + }, + }, + ) + + if len(got) != 1 { + t.Fatalf("candidates len = %d, want 1", len(got)) + } + if got[0].Kind != "repayment" || got[0].DebtID != "" || got[0].Direction != "lent" { + t.Fatalf("candidate = %+v, want app-verified repayment without backend debt id", got[0]) + } + if got[0].Splits[0].BalanceID != balanceIDStr { + t.Fatalf("balance_id = %q, want %q", got[0].Splits[0].BalanceID, balanceIDStr) + } + if got[0].IncludeInAnalytics { + t.Fatal("repayment candidate should not be included in analytics") + } +} diff --git a/internal/handlers/webhook.go b/internal/handlers/webhook.go index f4664d1..b4683d7 100644 --- a/internal/handlers/webhook.go +++ b/internal/handlers/webhook.go @@ -1,7 +1,10 @@ package handlers import ( + "context" + "encoding/base64" "encoding/json" + "errors" "fmt" "io" "log/slog" @@ -12,6 +15,7 @@ import ( "strings" "time" + "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" "github.com/labstack/echo/v4" "github.com/redis/go-redis/v9" @@ -30,6 +34,11 @@ type revenueCatWebhookPayload struct { } `json:"event"` } +type polarWebhookEvent struct { + Type string `json:"type"` + Data map[string]interface{} `json:"data"` +} + // RevenueCatWebhookHandler handles RevenueCat webhook events. // POST /api/v1/webhooks/revenuecat func (S *Server) RevenueCatWebhookHandler(c echo.Context) error { @@ -54,59 +63,203 @@ func (S *Server) RevenueCatWebhookHandler(c echo.Context) error { return c.JSON(http.StatusOK, map[string]string{"status": "ok"}) } - // Idempotency check via Redis (NX: only set if key doesn't exist) - eventKey := fmt.Sprintf("rc_event:%s", payload.Event.EventID) - result, _ := S.Redis.SetArgs(ctx, eventKey, "1", redis.SetArgs{ - TTL: 24 * time.Hour, - Mode: "NX", - }).Result() - if result != "OK" { - return c.JSON(http.StatusOK, map[string]string{"status": "duplicate"}) + revenueCatEventKey := "" + if payload.Event.EventID != "" && S.Redis != nil { + eventKey := fmt.Sprintf("rc_event:%s", payload.Event.EventID) + result, err := S.Redis.SetArgs(ctx, eventKey, "1", redis.SetArgs{ + TTL: 24 * time.Hour, + Mode: "NX", + }).Result() + if err == redis.Nil || (err == nil && result != "OK") { + return c.JSON(http.StatusOK, map[string]string{"status": "duplicate"}) + } + if err != nil && err != redis.Nil { + S.LogErr(c, op, fmt.Errorf("revenuecat idempotency: %w", err)) + } + if err == nil && result == "OK" { + revenueCatEventKey = eventKey + } + } + + if err := S.processRevenueCatWebhookPayload(ctx, payload); err != nil { + if revenueCatEventKey != "" { + _ = S.Redis.Del(ctx, revenueCatEventKey).Err() + } + S.LogErr(c, op, err) + return c.JSON(http.StatusServiceUnavailable, map[string]string{"status": "retry"}) + } + + slog.Info("RevenueCat webhook processed", "type", payload.Event.Type, "user", payload.Event.AppUserID) + return c.JSON(http.StatusOK, map[string]string{"status": "ok"}) +} + +func (S *Server) revenueCatWebhookSecret() string { + if S.ConfigCache != nil { + return strings.Trim(strings.TrimSpace(S.ConfigCache.GetString("revenuecat_webhook_secret", "")), `"`) + } + return "" +} + +func (S *Server) polarWebhookSecret() string { + var raw string + if S.ConfigCache != nil { + raw = strings.Trim(strings.TrimSpace(S.ConfigCache.GetString("polar_webhook_secret", "")), `"`) + } + if raw == "" { + raw = strings.Trim(strings.TrimSpace(config.EnVar.PolarWebhookSecret), `"`) + } + return normalizePolarWebhookSecret(raw) +} + +func normalizePolarWebhookSecret(raw string) string { + raw = strings.Trim(strings.TrimSpace(raw), `"`) + if raw == "" { + return "" + } + // Polar exposes secrets like polar_whs_... while Standard Webhooks libraries + // expect a base64-encoded signing key (optionally prefixed with whsec_). + if strings.HasPrefix(raw, "polar_whs_") { + return "whsec_" + base64.StdEncoding.EncodeToString([]byte(raw)) + } + return raw +} + +// --- Polar Webhook --- + +// PolarWebhookHandler handles Polar.sh webhook events verified via Svix. +// POST /api/v1/webhooks/polar +func (S *Server) PolarWebhookHandler(c echo.Context) error { + const op = "PolarWebhookHandler" + ctx := c.Request().Context() + + body, err := io.ReadAll(c.Request().Body) + if err != nil { + S.LogErr(c, op, err) + return c.JSON(http.StatusOK, map[string]string{"status": "ok"}) + } + + webhookID := c.Request().Header.Get("webhook-id") + slog.Info("polar webhook received", + "webhook_id", webhookID, + "content_length", len(body), + ) + + // Verify signature using Svix + wh, err := svix.NewWebhook(S.polarWebhookSecret()) + if err != nil { + S.LogErr(c, op, fmt.Errorf("svix init: %w", err)) + return c.JSON(http.StatusOK, map[string]string{"status": "invalid_secret"}) + } + if err := wh.Verify(body, c.Request().Header); err != nil { + S.LogErr(c, op, fmt.Errorf("polar webhook signature invalid: %w", err)) + return c.JSON(http.StatusOK, map[string]string{"status": "invalid_signature"}) + } + + var event polarWebhookEvent + if err := json.Unmarshal(body, &event); err != nil { + S.LogErr(c, op, err) + return c.JSON(http.StatusOK, map[string]string{"status": "ok"}) + } + + slog.Info("polar webhook parsed", + "webhook_id", webhookID, + "type", event.Type, + "data_id", debugMapString(event.Data, "id"), + "product_id", debugMapString(event.Data, "product_id"), + "checkout_id", debugMapString(event.Data, "checkout_id"), + "customer_external_id", debugNestedMapString(event.Data, "customer", "external_id"), + "user_external_id", debugNestedMapString(event.Data, "user", "external_id"), + ) + + eventID := webhookID + if eventID == "" { + eventID = debugMapString(event.Data, "id") + } + + polarEventKey := "" + if eventID != "" && S.Redis != nil { + eventKey := fmt.Sprintf("polar_event:%s", eventID) + result, err := S.Redis.SetArgs(ctx, eventKey, "1", redis.SetArgs{ + TTL: 24 * time.Hour, + Mode: "NX", + }).Result() + if err == redis.Nil || (err == nil && result != "OK") { + return c.JSON(http.StatusOK, map[string]string{"status": "duplicate"}) + } + if err != nil && err != redis.Nil { + S.LogErr(c, op, fmt.Errorf("polar idempotency: %w", err)) + } + if err == nil && result == "OK" { + polarEventKey = eventKey + } } - // Parse user UUID + if err := S.processPolarWebhookEvent(ctx, event); err != nil { + if polarEventKey != "" { + _ = S.Redis.Del(ctx, polarEventKey).Err() + } + S.LogErr(c, op, err) + return c.JSON(http.StatusServiceUnavailable, map[string]string{"status": "retry"}) + } + + slog.Info("Polar webhook processed", "type", event.Type) + return c.JSON(http.StatusOK, map[string]string{"status": "ok"}) +} + +func debugMapString(data map[string]interface{}, key string) string { + value, _ := data[key].(string) + return value +} + +func debugNestedMapString(data map[string]interface{}, outerKey, innerKey string) string { + nested, ok := data[outerKey].(map[string]interface{}) + if !ok { + return "" + } + value, _ := nested[innerKey].(string) + return value +} + +func (S *Server) processRevenueCatWebhookPayload(ctx context.Context, payload revenueCatWebhookPayload) error { userUUID := pgtype.UUID{} if err := userUUID.Scan(payload.Event.AppUserID); err != nil { - S.LogErr(c, op, fmt.Errorf("invalid app_user_id: %s", payload.Event.AppUserID)) - return c.JSON(http.StatusOK, map[string]string{"status": "ok"}) + slog.Warn("revenuecat webhook: invalid app_user_id", "app_user_id", payload.Event.AppUserID) + return nil } switch payload.Event.Type { case "INITIAL_PURCHASE", "RENEWAL": var expiresAt time.Time if payload.Event.ExpirationAt != "" { - // Parse ms timestamp var ms int64 _, _ = fmt.Sscanf(payload.Event.ExpirationAt, "%d", &ms) expiresAt = time.UnixMilli(ms) } else { - expiresAt = time.Now().AddDate(0, 1, 0) // default 1 month + expiresAt = time.Now().AddDate(0, 1, 0) } - // Upsert entitlement - _ = S.Queries.UpsertEntitlement(ctx, queries.UpsertEntitlementParams{ + if err := S.Queries.UpsertEntitlement(ctx, queries.UpsertEntitlementParams{ UserID: userUUID, PlanID: "pro", ActiveUntil: pgtype.Timestamptz{Time: expiresAt, Valid: true}, - }) + }); err != nil { + return err + } - // Check if subscription exists, create if not sub, err := S.Queries.GetActiveSubscriptionByUserID(ctx, userUUID) if err != nil { - // Look up store product product, prodErr := S.Queries.GetStoreProductByProviderAndProductID(ctx, queries.GetStoreProductByProviderAndProductIDParams{ - Provider: "ios", // or android - RevenueCat handles both + Provider: "ios", StoreProductID: payload.Event.ProductID, }) if prodErr != nil { - // Try android product, prodErr = S.Queries.GetStoreProductByProviderAndProductID(ctx, queries.GetStoreProductByProviderAndProductIDParams{ Provider: "android", StoreProductID: payload.Event.ProductID, }) } if prodErr == nil { - _, _ = S.Queries.CreateSubscription(ctx, queries.CreateSubscriptionParams{ + _, err = S.Queries.CreateSubscription(ctx, queries.CreateSubscriptionParams{ UserID: userUUID, PlanID: "pro", ProductID: product.ID, @@ -119,153 +272,81 @@ func (S *Server) RevenueCatWebhookHandler(c echo.Context) error { CurrentPeriodStart: pgtype.Timestamptz{Time: time.Now(), Valid: true}, CurrentPeriodEnd: pgtype.Timestamptz{Time: expiresAt, Valid: true}, }) + return err } - } else { - // Update existing subscription period - _ = S.Queries.UpdateSubscriptionPeriod(ctx, queries.UpdateSubscriptionPeriodParams{ - ID: sub.ID, - CurrentPeriodStart: pgtype.Timestamptz{Time: time.Now(), Valid: true}, - CurrentPeriodEnd: pgtype.Timestamptz{Time: expiresAt, Valid: true}, - }) + slog.Warn("revenuecat webhook: product not found", "product_id", payload.Event.ProductID) + return nil } + return S.Queries.UpdateSubscriptionPeriod(ctx, queries.UpdateSubscriptionPeriodParams{ + ID: sub.ID, + CurrentPeriodStart: pgtype.Timestamptz{Time: time.Now(), Valid: true}, + CurrentPeriodEnd: pgtype.Timestamptz{Time: expiresAt, Valid: true}, + }) + case "CANCELLATION": sub, err := S.Queries.GetActiveSubscriptionByUserID(ctx, userUUID) if err == nil { - _ = S.Queries.SetSubscriptionCancelAtPeriodEnd(ctx, queries.SetSubscriptionCancelAtPeriodEndParams{ + return S.Queries.SetSubscriptionCancelAtPeriodEnd(ctx, queries.SetSubscriptionCancelAtPeriodEndParams{ ID: sub.ID, CancelAtPeriodEnd: true, }) } + return nil case "EXPIRATION": sub, err := S.Queries.GetActiveSubscriptionByUserID(ctx, userUUID) if err == nil { - _ = S.Queries.UpdateSubscriptionStatus(ctx, queries.UpdateSubscriptionStatusParams{ + if err := S.Queries.UpdateSubscriptionStatus(ctx, queries.UpdateSubscriptionStatusParams{ ID: sub.ID, Status: "expired", - }) + }); err != nil { + return err + } } - // Downgrade to free - _ = S.Queries.UpsertEntitlement(ctx, queries.UpsertEntitlementParams{ + if err := S.Queries.UpsertEntitlement(ctx, queries.UpsertEntitlementParams{ UserID: userUUID, PlanID: "free", ActiveUntil: pgtype.Timestamptz{Time: time.Now(), Valid: true}, - }) + }); err != nil { + return err + } recordDowngrade(ctx, S.Queries, userUUID, "store_expired", nil) + return nil case "BILLING_ISSUE": sub, err := S.Queries.GetActiveSubscriptionByUserID(ctx, userUUID) if err == nil { - _ = S.Queries.UpdateSubscriptionStatus(ctx, queries.UpdateSubscriptionStatusParams{ + return S.Queries.UpdateSubscriptionStatus(ctx, queries.UpdateSubscriptionStatusParams{ ID: sub.ID, Status: "past_due", }) } - } - - slog.Info("RevenueCat webhook processed", "type", payload.Event.Type, "user", payload.Event.AppUserID) - return c.JSON(http.StatusOK, map[string]string{"status": "ok"}) -} - -func (S *Server) revenueCatWebhookSecret() string { - if S.ConfigCache != nil { - if secret := strings.Trim(strings.TrimSpace(S.ConfigCache.GetString("revenuecat_webhook_secret", "")), `"`); secret != "" { - return secret - } - } - return strings.TrimSpace(config.EnVar.RevenueCatWebhookSecret) -} + return nil -func (S *Server) polarWebhookSecret() string { - if S.ConfigCache != nil { - if secret := strings.Trim(strings.TrimSpace(S.ConfigCache.GetString("polar_webhook_secret", "")), `"`); secret != "" { - return secret - } + default: + slog.Info("revenuecat webhook ignored", "type", payload.Event.Type) + return nil } - return strings.TrimSpace(config.EnVar.PolarWebhookSecret) } -// --- Polar Webhook --- - -// PolarWebhookHandler handles Polar.sh webhook events verified via Svix. -// POST /api/v1/webhooks/polar -func (S *Server) PolarWebhookHandler(c echo.Context) error { - const op = "PolarWebhookHandler" - ctx := c.Request().Context() - - body, err := io.ReadAll(c.Request().Body) - if err != nil { - S.LogErr(c, op, err) - return c.JSON(http.StatusOK, map[string]string{"status": "ok"}) - } - - webhookID := c.Request().Header.Get("webhook-id") - slog.Info("TEMP DEBUG polar webhook received", - "webhook_id", webhookID, - "content_length", len(body), - ) - - // Verify signature using Svix - wh, err := svix.NewWebhook(S.polarWebhookSecret()) - if err != nil { - S.LogErr(c, op, fmt.Errorf("svix init: %w", err)) - return c.JSON(http.StatusOK, map[string]string{"status": "invalid_secret"}) - } - if err := wh.Verify(body, c.Request().Header); err != nil { - S.LogErr(c, op, fmt.Errorf("polar webhook signature invalid: %w", err)) - return c.JSON(http.StatusOK, map[string]string{"status": "invalid_signature"}) - } - - var event struct { - Type string `json:"type"` - Data map[string]interface{} `json:"data"` - } - if err := json.Unmarshal(body, &event); err != nil { - S.LogErr(c, op, err) - return c.JSON(http.StatusOK, map[string]string{"status": "ok"}) - } - - slog.Info("TEMP DEBUG polar webhook parsed", - "webhook_id", webhookID, - "type", event.Type, - "data_id", debugMapString(event.Data, "id"), - "product_id", debugMapString(event.Data, "product_id"), - "checkout_id", debugMapString(event.Data, "checkout_id"), - "customer_external_id", debugNestedMapString(event.Data, "customer", "external_id"), - "user_external_id", debugNestedMapString(event.Data, "user", "external_id"), - ) - - // Idempotency — use webhook-id header as event ID - eventID := webhookID - if eventID != "" { - eventKey := fmt.Sprintf("polar_event:%s", eventID) - result, _ := S.Redis.SetArgs(ctx, eventKey, "1", redis.SetArgs{ - TTL: 24 * time.Hour, - Mode: "NX", - }).Result() - if result != "OK" { - return c.JSON(http.StatusOK, map[string]string{"status": "duplicate"}) - } - } - +func (S *Server) processPolarWebhookEvent(ctx context.Context, event polarWebhookEvent) error { switch event.Type { case "subscription.created", "subscription.active": - S.handlePolarSubscriptionCreated(c, event.Data) - case "subscription.updated": - S.handlePolarSubscriptionUpdated(c, event.Data) - case "subscription.canceled", "subscription.revoked": - S.handlePolarSubscriptionCanceled(c, event.Data) + return S.handlePolarSubscriptionCreatedEvent(ctx, event.Data) + case "subscription.updated", "subscription.uncanceled", "subscription.past_due": + return S.handlePolarSubscriptionUpdatedEvent(ctx, event.Data) + case "subscription.canceled": + return S.handlePolarSubscriptionCanceledEvent(ctx, event.Data) + case "subscription.revoked": + return S.handlePolarSubscriptionRevokedEvent(ctx, event.Data) + default: + slog.Info("polar webhook ignored", "type", event.Type) + return nil } - - slog.Info("Polar webhook processed", "type", event.Type) - return c.JSON(http.StatusOK, map[string]string{"status": "ok"}) } -func (S *Server) handlePolarSubscriptionCreated(c echo.Context, data map[string]interface{}) { - ctx := c.Request().Context() - - // data["customer"]["external_id"] is the user UUID set at checkout +func (S *Server) handlePolarSubscriptionCreatedEvent(ctx context.Context, data map[string]interface{}) error { userIDStr := "" if customer, ok := data["customer"].(map[string]interface{}); ok { userIDStr, _ = customer["external_id"].(string) @@ -276,83 +357,88 @@ func (S *Server) handlePolarSubscriptionCreated(c echo.Context, data map[string] } } if userIDStr == "" { - slog.Warn("TEMP DEBUG polar webhook missing external_id on subscription create", + slog.Warn("polar webhook missing external_id on subscription create", "data_id", debugMapString(data, "id"), "product_id", debugMapString(data, "product_id"), "customer_external_id", debugNestedMapString(data, "customer", "external_id"), "user_external_id", debugNestedMapString(data, "user", "external_id"), ) - return + return nil } userUUID := pgtype.UUID{} if err := userUUID.Scan(userIDStr); err != nil { - slog.Warn("TEMP DEBUG polar webhook external_id is not a valid UUID", + slog.Warn("polar webhook external_id is not a valid UUID", "external_id", userIDStr, "data_id", debugMapString(data, "id"), ) - return + return nil } productID, _ := data["product_id"].(string) subscriptionID, _ := data["id"].(string) - slog.Info("TEMP DEBUG polar subscription create correlation", - "user_id", userIDStr, - "product_id", productID, - "subscription_id", subscriptionID, - "checkout_id", debugMapString(data, "checkout_id"), - ) - // Resolve store_products row by polar product ID product, err := S.Queries.GetStoreProductByProviderAndProductID(ctx, queries.GetStoreProductByProviderAndProductIDParams{ Provider: "polar", StoreProductID: productID, }) if err != nil { - S.LogErr(c, "handlePolarSubscriptionCreated", fmt.Errorf("product not found: %s", productID)) - slog.Warn("TEMP DEBUG polar product lookup failed", - "user_id", userIDStr, - "product_id", productID, - "subscription_id", subscriptionID, - ) - return + slog.Warn("polar webhook product not found", "product_id", productID, "subscription_id", subscriptionID) + return nil } now := time.Now() - periodEnd := utils.ComputePeriodEnd(now, "UTC", product.Period) + periodStart := polarTimeField(data, "current_period_start", now) + periodEnd := polarTimeField(data, "current_period_end", utils.ComputePeriodEnd(now, "UTC", product.Period)) - sub, err := S.Queries.CreateSubscription(ctx, queries.CreateSubscriptionParams{ - UserID: userUUID, - PlanID: product.PlanID, - ProductID: product.ID, + sub, err := S.Queries.GetSubscriptionByProviderID(ctx, queries.GetSubscriptionByProviderIDParams{ Provider: "polar", - BillingPeriod: string(utils.NormalizeBillingPeriod(product.Period)), ProviderSubscriptionID: &subscriptionID, - Status: "active", - GraceDays: 0, - GraceUntil: pgtype.Timestamptz{}, - PastDueSince: pgtype.Timestamptz{}, - CurrentPeriodStart: pgtype.Timestamptz{Time: now, Valid: true}, - CurrentPeriodEnd: pgtype.Timestamptz{Time: periodEnd, Valid: true}, }) - if err != nil { - S.LogErr(c, "handlePolarSubscriptionCreated", fmt.Errorf("create subscription: %w", err)) - slog.Warn("TEMP DEBUG polar subscription create failed", - "user_id", userIDStr, - "product_id", productID, - "subscription_id", subscriptionID, - ) - return + if err == nil { + if err := S.Queries.UpdateSubscriptionStatus(ctx, queries.UpdateSubscriptionStatusParams{ + ID: sub.ID, + Status: "active", + }); err != nil { + return err + } + if err := S.Queries.UpdateSubscriptionPeriod(ctx, queries.UpdateSubscriptionPeriodParams{ + ID: sub.ID, + CurrentPeriodStart: pgtype.Timestamptz{Time: periodStart, Valid: true}, + CurrentPeriodEnd: pgtype.Timestamptz{Time: periodEnd, Valid: true}, + }); err != nil { + return err + } + if err := S.Queries.SetSubscriptionCancelAtPeriodEnd(ctx, queries.SetSubscriptionCancelAtPeriodEndParams{ + ID: sub.ID, + CancelAtPeriodEnd: false, + }); err != nil { + return err + } + } else { + if !errors.Is(err, pgx.ErrNoRows) { + return err + } + sub, err = S.Queries.CreateSubscription(ctx, queries.CreateSubscriptionParams{ + UserID: userUUID, + PlanID: product.PlanID, + ProductID: product.ID, + Provider: "polar", + BillingPeriod: string(utils.NormalizeBillingPeriod(product.Period)), + ProviderSubscriptionID: &subscriptionID, + Status: "active", + GraceDays: 0, + GraceUntil: pgtype.Timestamptz{}, + PastDueSince: pgtype.Timestamptz{}, + CurrentPeriodStart: pgtype.Timestamptz{Time: periodStart, Valid: true}, + CurrentPeriodEnd: pgtype.Timestamptz{Time: periodEnd, Valid: true}, + }) + if err != nil { + return err + } } - slog.Info("TEMP DEBUG polar subscription created locally", - "subscription_row_id", sub.ID, - "user_id", userIDStr, - "product_id", productID, - "provider_subscription_id", subscriptionID, - "period_end", periodEnd, - ) - err = S.Queries.UpsertEntitlement(ctx, queries.UpsertEntitlementParams{ + if err := S.Queries.UpsertEntitlement(ctx, queries.UpsertEntitlementParams{ UserID: userUUID, PlanID: product.PlanID, BillingPeriod: func() *string { @@ -360,30 +446,25 @@ func (S *Server) handlePolarSubscriptionCreated(c echo.Context, data map[string] return &period }(), ActiveUntil: pgtype.Timestamptz{Time: periodEnd, Valid: true}, - }) - if err != nil { - S.LogErr(c, "handlePolarSubscriptionCreated", fmt.Errorf("upsert entitlement: %w", err)) - slog.Warn("TEMP DEBUG polar entitlement upsert failed", - "user_id", userIDStr, - "subscription_id", subscriptionID, - ) - return + }); err != nil { + return err } - slog.Info("TEMP DEBUG polar entitlement activated", + + slog.Info("polar subscription created locally", + "subscription_row_id", sub.ID, "user_id", userIDStr, - "plan_id", product.PlanID, - "subscription_id", subscriptionID, - "active_until", periodEnd, + "product_id", productID, + "provider_subscription_id", subscriptionID, + "period_end", periodEnd, ) + return nil } -func (S *Server) handlePolarSubscriptionUpdated(c echo.Context, data map[string]interface{}) { - ctx := c.Request().Context() - +func (S *Server) handlePolarSubscriptionUpdatedEvent(ctx context.Context, data map[string]interface{}) error { subscriptionID, _ := data["id"].(string) if subscriptionID == "" { - slog.Warn("TEMP DEBUG polar subscription update missing subscription id") - return + slog.Warn("polar webhook subscription update missing subscription id") + return nil } sub, err := S.Queries.GetSubscriptionByProviderID(ctx, queries.GetSubscriptionByProviderIDParams{ @@ -391,43 +472,62 @@ func (S *Server) handlePolarSubscriptionUpdated(c echo.Context, data map[string] ProviderSubscriptionID: &subscriptionID, }) if err != nil { - S.LogErr(c, "handlePolarSubscriptionUpdated", fmt.Errorf("get subscription by provider id: %w", err)) - slog.Warn("TEMP DEBUG polar local subscription lookup failed on update", - "subscription_id", subscriptionID, - ) - return + slog.Warn("polar webhook local subscription lookup failed on update", "subscription_id", subscriptionID) + return nil } now := time.Now() - periodEnd := utils.ComputePeriodEnd(now, "UTC", sub.BillingPeriod) + periodStart := polarTimeField(data, "current_period_start", now) + periodEnd := polarTimeField(data, "current_period_end", utils.ComputePeriodEnd(now, "UTC", sub.BillingPeriod)) - err = S.Queries.UpdateSubscriptionPeriod(ctx, queries.UpdateSubscriptionPeriodParams{ + if err := S.Queries.UpdateSubscriptionPeriod(ctx, queries.UpdateSubscriptionPeriodParams{ ID: sub.ID, - CurrentPeriodStart: pgtype.Timestamptz{Time: now, Valid: true}, + CurrentPeriodStart: pgtype.Timestamptz{Time: periodStart, Valid: true}, CurrentPeriodEnd: pgtype.Timestamptz{Time: periodEnd, Valid: true}, - }) - if err != nil { - S.LogErr(c, "handlePolarSubscriptionUpdated", fmt.Errorf("update subscription period: %w", err)) - slog.Warn("TEMP DEBUG polar local subscription period update failed", - "subscription_id", subscriptionID, - "subscription_row_id", sub.ID, - ) - return + }); err != nil { + return err } - slog.Info("TEMP DEBUG polar local subscription period updated", + if status, _ := data["status"].(string); status == "active" || status == "past_due" { + localStatus := status + if err := S.Queries.UpdateSubscriptionStatus(ctx, queries.UpdateSubscriptionStatusParams{ + ID: sub.ID, + Status: localStatus, + }); err != nil { + return err + } + if localStatus == "active" && periodEnd.After(time.Now()) { + if err := S.Queries.UpsertEntitlement(ctx, queries.UpsertEntitlementParams{ + UserID: sub.UserID, + PlanID: sub.PlanID, + BillingPeriod: &sub.BillingPeriod, + ActiveUntil: pgtype.Timestamptz{Time: periodEnd, Valid: true}, + }); err != nil { + return err + } + } + } + if cancelAtPeriodEnd, ok := data["cancel_at_period_end"].(bool); ok { + if err := S.Queries.SetSubscriptionCancelAtPeriodEnd(ctx, queries.SetSubscriptionCancelAtPeriodEndParams{ + ID: sub.ID, + CancelAtPeriodEnd: cancelAtPeriodEnd, + }); err != nil { + return err + } + } + + slog.Info("polar local subscription period updated", "subscription_id", subscriptionID, "subscription_row_id", sub.ID, "period_end", periodEnd, ) + return nil } -func (S *Server) handlePolarSubscriptionCanceled(c echo.Context, data map[string]interface{}) { - ctx := c.Request().Context() - +func (S *Server) handlePolarSubscriptionCanceledEvent(ctx context.Context, data map[string]interface{}) error { subscriptionID, _ := data["id"].(string) if subscriptionID == "" { - slog.Warn("TEMP DEBUG polar subscription cancel missing subscription id") - return + slog.Warn("polar webhook cancel missing subscription id") + return nil } sub, err := S.Queries.GetSubscriptionByProviderID(ctx, queries.GetSubscriptionByProviderIDParams{ @@ -435,60 +535,106 @@ func (S *Server) handlePolarSubscriptionCanceled(c echo.Context, data map[string ProviderSubscriptionID: &subscriptionID, }) if err != nil { - S.LogErr(c, "handlePolarSubscriptionCanceled", fmt.Errorf("get subscription by provider id: %w", err)) - slog.Warn("TEMP DEBUG polar local subscription lookup failed on cancel", + slog.Warn("polar webhook local subscription lookup failed on cancel", "subscription_id", subscriptionID) + return nil + } + + periodEnd := polarTimeField(data, "current_period_end", sub.CurrentPeriodEnd.Time) + if periodEnd.After(time.Now()) { + if err := S.Queries.UpdateSubscriptionStatus(ctx, queries.UpdateSubscriptionStatusParams{ + ID: sub.ID, + Status: "active", + }); err != nil { + return err + } + if err := S.Queries.SetSubscriptionCancelAtPeriodEnd(ctx, queries.SetSubscriptionCancelAtPeriodEndParams{ + ID: sub.ID, + CancelAtPeriodEnd: true, + }); err != nil { + return err + } + if err := S.Queries.UpdateSubscriptionPeriod(ctx, queries.UpdateSubscriptionPeriodParams{ + ID: sub.ID, + CurrentPeriodStart: sub.CurrentPeriodStart, + CurrentPeriodEnd: pgtype.Timestamptz{Time: periodEnd, Valid: true}, + }); err != nil { + return err + } + if err := S.Queries.UpsertEntitlement(ctx, queries.UpsertEntitlementParams{ + UserID: sub.UserID, + PlanID: sub.PlanID, + BillingPeriod: &sub.BillingPeriod, + ActiveUntil: pgtype.Timestamptz{Time: periodEnd, Valid: true}, + }); err != nil { + return err + } + slog.Info("polar subscription marked cancel_at_period_end locally", "subscription_id", subscriptionID, + "subscription_row_id", sub.ID, + "user_id", sub.UserID, + "period_end", periodEnd, ) - return + return nil } - err = S.Queries.UpdateSubscriptionStatus(ctx, queries.UpdateSubscriptionStatusParams{ - ID: sub.ID, - Status: "expired", + return S.expirePolarSubscriptionNow(ctx, sub, subscriptionID, "polar_canceled") +} + +func (S *Server) handlePolarSubscriptionRevokedEvent(ctx context.Context, data map[string]interface{}) error { + subscriptionID, _ := data["id"].(string) + if subscriptionID == "" { + slog.Warn("polar webhook revoke missing subscription id") + return nil + } + + sub, err := S.Queries.GetSubscriptionByProviderID(ctx, queries.GetSubscriptionByProviderIDParams{ + Provider: "polar", + ProviderSubscriptionID: &subscriptionID, }) if err != nil { - S.LogErr(c, "handlePolarSubscriptionCanceled", fmt.Errorf("update subscription status: %w", err)) - slog.Warn("TEMP DEBUG polar local subscription expire failed", - "subscription_id", subscriptionID, - "subscription_row_id", sub.ID, - ) - return + slog.Warn("polar webhook local subscription lookup failed on revoke", "subscription_id", subscriptionID) + return nil + } + + return S.expirePolarSubscriptionNow(ctx, sub, subscriptionID, "polar_revoked") +} + +func (S *Server) expirePolarSubscriptionNow(ctx context.Context, sub queries.Subscription, subscriptionID, reason string) error { + if err := S.Queries.UpdateSubscriptionStatus(ctx, queries.UpdateSubscriptionStatusParams{ + ID: sub.ID, + Status: "expired", + }); err != nil { + return err } - err = S.Queries.UpsertEntitlement(ctx, queries.UpsertEntitlementParams{ + if err := S.Queries.UpsertEntitlement(ctx, queries.UpsertEntitlementParams{ UserID: sub.UserID, PlanID: "free", BillingPeriod: nil, ActiveUntil: pgtype.Timestamptz{Time: time.Now(), Valid: true}, - }) - if err != nil { - S.LogErr(c, "handlePolarSubscriptionCanceled", fmt.Errorf("downgrade entitlement: %w", err)) - slog.Warn("TEMP DEBUG polar entitlement downgrade failed", - "subscription_id", subscriptionID, - "subscription_row_id", sub.ID, - ) - return + }); err != nil { + return err } - slog.Info("TEMP DEBUG polar subscription canceled locally", + + recordDowngrade(ctx, S.Queries, sub.UserID, reason, nil) + slog.Info("polar subscription canceled locally", "subscription_id", subscriptionID, "subscription_row_id", sub.ID, "user_id", sub.UserID, ) - recordDowngrade(ctx, S.Queries, sub.UserID, "polar_canceled", nil) + return nil } -func debugMapString(data map[string]interface{}, key string) string { +func polarTimeField(data map[string]interface{}, key string, fallback time.Time) time.Time { value, _ := data[key].(string) - return value -} - -func debugNestedMapString(data map[string]interface{}, outerKey, innerKey string) string { - nested, ok := data[outerKey].(map[string]interface{}) - if !ok { - return "" + if value == "" { + return fallback } - value, _ := nested[innerKey].(string) - return value + parsed, err := time.Parse(time.RFC3339Nano, value) + if err != nil { + return fallback + } + return parsed } // --- Payme Merchant Webhook --- diff --git a/internal/handlers/webhook_test.go b/internal/handlers/webhook_test.go new file mode 100644 index 0000000..99efdbf --- /dev/null +++ b/internal/handlers/webhook_test.go @@ -0,0 +1,92 @@ +package handlers + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "net/http" + "testing" + + "numex-api/internal/cache" + "numex-api/internal/config" + + svix "github.com/svix/svix-webhooks/go" +) + +func TestPolarWebhookSecretAppConfigWinsOverEnvFallback(t *testing.T) { + original := config.EnVar.PolarWebhookSecret + t.Cleanup(func() { + config.EnVar.PolarWebhookSecret = original + }) + + config.EnVar.PolarWebhookSecret = "env_secret" + srv := &Server{ConfigCache: &cache.ConfigCache{}} + srv.ConfigCache.SetForTest("polar_webhook_secret", "app_config_secret") + + if got := srv.polarWebhookSecret(); got != "app_config_secret" { + t.Fatalf("polarWebhookSecret() = %q, want app config value", got) + } +} + +func TestPolarWebhookSecretUsesEnvFallbackWhenAppConfigMissing(t *testing.T) { + original := config.EnVar.PolarWebhookSecret + t.Cleanup(func() { + config.EnVar.PolarWebhookSecret = original + }) + + config.EnVar.PolarWebhookSecret = "env_secret" + srv := &Server{ConfigCache: &cache.ConfigCache{}} + + if got := srv.polarWebhookSecret(); got != "env_secret" { + t.Fatalf("polarWebhookSecret() = %q, want env fallback", got) + } +} + +func TestPolarWebhookSecretEmptyOnlyWhenAppConfigAndEnvMissing(t *testing.T) { + original := config.EnVar.PolarWebhookSecret + t.Cleanup(func() { + config.EnVar.PolarWebhookSecret = original + }) + + config.EnVar.PolarWebhookSecret = "" + srv := &Server{ConfigCache: &cache.ConfigCache{}} + + if got := srv.polarWebhookSecret(); got != "" { + t.Fatalf("polarWebhookSecret() = %q, want empty string", got) + } +} + +func TestNormalizePolarWebhookSecretForSvix(t *testing.T) { + rawSecret := "polar_" + "whs_" + "test_webhook_key_123" + + got := normalizePolarWebhookSecret(rawSecret) + want := "whsec_" + base64.StdEncoding.EncodeToString([]byte(rawSecret)) + if got != want { + t.Fatalf("normalizePolarWebhookSecret() = %q, want %q", got, want) + } +} + +func TestNormalizePolarWebhookSecretProducesVerifiableSvixSecret(t *testing.T) { + rawSecret := "polar_" + "whs_" + "test_webhook_key_123" + payload := []byte(`{"type":"subscription.created","data":{"id":"sub_test"}}`) + msgID := "msg_test" + + wh, err := svix.NewWebhook(normalizePolarWebhookSecret(rawSecret)) + if err != nil { + t.Fatalf("svix.NewWebhook() error = %v", err) + } + + toSign := []byte(msgID + "." + "1776223944" + "." + string(payload)) + mac := hmac.New(sha256.New, []byte(rawSecret)) + mac.Write(toSign) + signature := base64.StdEncoding.EncodeToString(mac.Sum(nil)) + + headers := http.Header{} + headers.Set("webhook-id", msgID) + headers.Set("webhook-timestamp", "1776223944") + headers.Set("webhook-signature", "v1,"+signature) + + if err := wh.VerifyIgnoringTimestamp(payload, headers); err != nil { + t.Fatalf("VerifyIgnoringTimestamp() error = %v", err) + } +} diff --git a/internal/middlewares/body_logger.go b/internal/middlewares/body_logger.go new file mode 100644 index 0000000..165c0ef --- /dev/null +++ b/internal/middlewares/body_logger.go @@ -0,0 +1,57 @@ +package middlewares + +import ( + "bytes" + "fmt" + "io" + "time" + + "github.com/labstack/echo/v4" + "github.com/labstack/echo/v4/middleware" +) + +func VerboseBodyLogger(w io.Writer, enabled bool, limit int) echo.MiddlewareFunc { + if !enabled { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return next + } + } + if limit <= 0 { + limit = 4096 + } + return middleware.BodyDumpWithConfig(middleware.BodyDumpConfig{ + Skipper: shouldSkipVerboseBodyLog, + Handler: func(c echo.Context, reqBody, resBody []byte) { + if IsNoLogZone(c) || shouldSkipVerboseBodyLog(c) { + return + } + _, _ = fmt.Fprintf( + w, + "[%s] BODY %s %s %d request_id=%s request_body=%s response_body=%s\n", + time.Now().Format("02/Jan/2006 15:04:05"), + c.Request().Method, + c.Request().URL.RequestURI(), + c.Response().Status, + c.Response().Header().Get(echo.HeaderXRequestID), + truncatedBody(reqBody, limit), + truncatedBody(resBody, limit), + ) + }, + }) +} + +func shouldSkipVerboseBodyLog(c echo.Context) bool { + // allow sensitive body log paths, we need that for now + return false +} + +func truncatedBody(body []byte, limit int) string { + if len(body) == 0 { + return `""` + } + body = bytes.TrimSpace(body) + if len(body) <= limit { + return fmt.Sprintf("%q", string(body)) + } + return fmt.Sprintf("%q...", string(body[:limit]), len(body)-limit) +} diff --git a/internal/middlewares/limits.go b/internal/middlewares/limits.go new file mode 100644 index 0000000..81c6328 --- /dev/null +++ b/internal/middlewares/limits.go @@ -0,0 +1,61 @@ +package middlewares + +import ( + "fmt" + "net/http" + "time" + + "numex-api/internal/models" + "numex-api/internal/msg" + "numex-api/internal/services" + + "github.com/labstack/echo/v4" + "github.com/redis/go-redis/v9" +) + +// AIAdmissionConfig controls per-user rate limiting for AI-driven routes +// (voice parse, text parse, insight generation). +type AIAdmissionConfig struct { + // KeyPrefix is used to namespace the Redis counter (e.g. "voice", "text_parse"). + KeyPrefix string + // Limit is the maximum number of requests allowed per user within Window. + Limit int64 + // Window is the sliding counter TTL (e.g. time.Minute). + Window time.Duration +} + +// UserAIAdmission returns a route-level Echo middleware that enforces a +// per-user request rate limit for AI endpoints using a Redis sliding counter. +// +// It must run AFTER JWTMiddleware so that c.Get("user") is populated. +func UserAIAdmission(redisClient *redis.Client, cfg AIAdmissionConfig) echo.MiddlewareFunc { + store := services.NewRedisCounterStore(redisClient) + + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + claims, ok := c.Get("user").(*models.CustomClaims) + if !ok || claims.Subject == "" { + // JWT middleware did not run or claims are absent — let the + // auth middleware handle the 401, don't block here. + return next(c) + } + + key := fmt.Sprintf("ai_admission:%s:%s", cfg.KeyPrefix, claims.Subject) + svc := services.NewAdmissionService(store) + allowed, _, err := svc.AllowUserWindow(c.Request().Context(), key, cfg.Limit, cfg.Window) + if err != nil { + // Redis error: fail open (log it, don't block the user). + c.Logger().Warnf("admission counter error for %s: %v", key, err) + return next(c) + } + if !allowed { + return c.JSON(http.StatusTooManyRequests, map[string]string{ + "message": msg.ErrAIRateLimited, + "code": msg.CodeAIRateLimited, + }) + } + + return next(c) + } + } +} diff --git a/internal/middlewares/logger.go b/internal/middlewares/logger.go index c87d88c..f7b266a 100644 --- a/internal/middlewares/logger.go +++ b/internal/middlewares/logger.go @@ -2,14 +2,14 @@ package middlewares import ( "fmt" - "os" + "io" "time" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" ) -func Logger() echo.MiddlewareFunc { +func Logger(w io.Writer) echo.MiddlewareFunc { return middleware.RequestLoggerWithConfig(middleware.RequestLoggerConfig{ LogMethod: true, LogURI: true, @@ -19,7 +19,7 @@ func Logger() echo.MiddlewareFunc { LogRequestID: true, LogValuesFunc: func(c echo.Context, v middleware.RequestLoggerValues) error { _, err := fmt.Fprintf( - os.Stdout, + w, "[%s] %s %s %d %s %s %s\n", v.StartTime.Format("02/Jan/2006 15:04:05"), v.Method, diff --git a/internal/models/balance.go b/internal/models/balance.go index 04c5f41..3445497 100644 --- a/internal/models/balance.go +++ b/internal/models/balance.go @@ -1,12 +1,15 @@ package models type CreateBalanceRequest struct { - Name string `json:"name" validate:"required,max=100"` - Description string `json:"description" validate:"max=500"` - Currency string `json:"currency" validate:"required,len=3"` - InitialAmountMinor int64 `json:"initial_amount_minor" validate:"required"` - ColorToken string `json:"color_token" validate:"required,max=32"` - SortOrder int `json:"sort_order"` + Name string `json:"name" validate:"required,max=100"` + EncryptedName string `json:"encrypted_name"` + Description string `json:"description" validate:"max=500"` + EncryptedDescription string `json:"encrypted_description"` + Currency string `json:"currency" validate:"required,len=3"` + InitialAmountMinor int64 `json:"initial_amount_minor"` + EncryptedInitialAmount string `json:"encrypted_initial_amount"` + ColorToken string `json:"color_token" validate:"required,max=32"` + SortOrder int `json:"sort_order"` } type UpdateBalanceRequest struct { diff --git a/internal/models/debt.go b/internal/models/debt.go index 048365a..ca2c297 100644 --- a/internal/models/debt.go +++ b/internal/models/debt.go @@ -23,6 +23,7 @@ type DebtResponse struct { Direction string `json:"direction"` AmountMinorOriginal int64 `json:"amount_minor_original"` AmountMinorRemaining int64 `json:"amount_minor_remaining"` + OverpaidAmountMinor int64 `json:"overpaid_amount_minor"` Currency string `json:"currency"` Note *string `json:"note,omitempty"` Status string `json:"status"` diff --git a/internal/models/debt_bundle.go b/internal/models/debt_bundle.go new file mode 100644 index 0000000..b607ef5 --- /dev/null +++ b/internal/models/debt_bundle.go @@ -0,0 +1,49 @@ +package models + +type DebtBundleSplitRequest struct { + BalanceID string `json:"balance_id"` + TransactionID string `json:"transaction_id"` + AmountMinor int64 `json:"amount_minor" validate:"required,gt=0"` + Currency string `json:"currency" validate:"required,len=3"` + EncryptedAmount string `json:"encrypted_amount"` + EncryptedMerchant string `json:"encrypted_merchant"` + EncryptedNote string `json:"encrypted_note"` + EncryptedRawQuery string `json:"encrypted_raw_query"` +} + +type DebtBundleEventRequest struct { + Kind string `json:"kind" validate:"required,oneof=creation repayment adjustment"` + Direction string `json:"direction" validate:"required,oneof=lent owed"` + Counterparty string `json:"counterparty" validate:"required,max=120"` + EncryptedCounterparty string `json:"encrypted_counterparty"` + DebtID string `json:"debt_id"` + SourceTransactionID string `json:"source_transaction_id"` + AmountMinor int64 `json:"amount_minor" validate:"required,gt=0"` + Currency string `json:"currency" validate:"required,len=3"` + ImpactAmountMinor int64 `json:"impact_amount_minor" validate:"required,gt=0"` + ImpactCurrency string `json:"impact_currency" validate:"required,len=3"` + ExchangeRate *string `json:"exchange_rate"` + ExchangeRateDate string `json:"exchange_rate_date"` + IncludeInAnalytics bool `json:"include_in_analytics"` + Merchant string `json:"merchant"` + EncryptedMerchant string `json:"encrypted_merchant"` + Note string `json:"note"` + EncryptedNote string `json:"encrypted_note"` + Source string `json:"source" validate:"required,oneof=voice chat_manual transaction_manual debt_screen convert"` + IdempotencyKey string `json:"idempotency_key" validate:"required"` + Splits []DebtBundleSplitRequest `json:"splits" validate:"required,min=1,dive"` +} + +type DebtBundleResponse struct { + Debt DebtResponse `json:"debt"` + EventID string `json:"event_id"` + Transactions []DebtBundleTransactionResponse `json:"transactions"` +} + +type DebtBundleTransactionResponse struct { + ID string `json:"id"` + BalanceID *string `json:"balance_id,omitempty"` + AmountMinor int64 `json:"amount_minor"` + Currency string `json:"currency"` + IncludeInAnalytics bool `json:"include_in_analytics"` +} diff --git a/internal/models/parse.go b/internal/models/parse.go index 209ffad..e9038d0 100644 --- a/internal/models/parse.go +++ b/internal/models/parse.go @@ -33,22 +33,42 @@ type GeminiDebtTransactionLink struct { Confidence float64 `json:"confidence"` } +type GeminiDebtBundleCandidate struct { + Kind string `json:"kind"` + Direction string `json:"direction"` + Counterparty string `json:"counterparty"` + DebtID string `json:"debt_id,omitempty"` + AmountMinor int64 `json:"amount_minor"` + Currency string `json:"currency"` + ImpactAmountMinor int64 `json:"impact_amount_minor"` + ImpactCurrency string `json:"impact_currency"` + IncludeInAnalytics bool `json:"include_in_analytics"` + Merchant string `json:"merchant,omitempty"` + Note string `json:"note,omitempty"` + Source string `json:"source"` + IdempotencyKey string `json:"idempotency_key,omitempty"` + Splits []DebtBundleSplitRequest `json:"splits"` + Confidence float64 `json:"confidence,omitempty"` +} + type MultiParseResponse struct { Transactions []ParseTransactionResponse `json:"transactions"` Language string `json:"language"` Transcript string `json:"transcript"` Debts []GeminiDebtItem `json:"debts,omitempty"` DebtTransactions []GeminiDebtTransactionLink `json:"debt_transactions,omitempty"` + DebtBundles []GeminiDebtBundleCandidate `json:"debt_bundles,omitempty"` LowConfidenceDebts []GeminiDebtItem `json:"low_confidence_debts,omitempty"` } type VoiceSubmitResponse struct { - Transactions []any `json:"transactions"` - Debts []any `json:"debts"` - Language string `json:"language"` - RawTranscript string `json:"raw_transcript"` - LowConfidenceDebts []GeminiDebtItem `json:"low_confidence_debts,omitempty"` - SkippedTransactions int `json:"skipped_transactions,omitempty"` + Transactions []any `json:"transactions"` + Debts []any `json:"debts"` + DebtBundles []GeminiDebtBundleCandidate `json:"debt_bundles,omitempty"` + Language string `json:"language"` + RawTranscript string `json:"raw_transcript"` + LowConfidenceDebts []GeminiDebtItem `json:"low_confidence_debts,omitempty"` + SkippedTransactions int `json:"skipped_transactions,omitempty"` } type VoiceTransactionRequest struct { diff --git a/internal/models/sync.go b/internal/models/sync.go index 735a038..42d9d52 100644 --- a/internal/models/sync.go +++ b/internal/models/sync.go @@ -32,7 +32,7 @@ type SyncBalanceCreate struct { EncryptedDescription string `json:"encrypted_description"` EncryptedInitialAmount string `json:"encrypted_initial_amount" validate:"required"` Currency string `json:"currency" validate:"required,len=3"` - InitialAmountMinor int64 `json:"initial_amount_minor" validate:"required"` + InitialAmountMinor int64 `json:"initial_amount_minor"` ColorToken string `json:"color_token" validate:"required,max=32"` SortOrder int `json:"sort_order"` } @@ -129,9 +129,10 @@ type SyncPushEntityResult struct { } type SyncPushResults struct { - Categories SyncPushEntityResult `json:"categories"` - Balances SyncPushEntityResult `json:"balances"` - Transactions SyncPushEntityResult `json:"transactions"` + Categories SyncPushEntityResult `json:"categories"` + Balances SyncPushEntityResult `json:"balances"` + Transactions SyncPushEntityResult `json:"transactions"` + BalanceSnapshots SyncPushEntityResult `json:"balance_snapshots"` } // SyncCategory is returned in the pull phase diff --git a/internal/models/transaction.go b/internal/models/transaction.go index 3a777dc..ea8ede7 100644 --- a/internal/models/transaction.go +++ b/internal/models/transaction.go @@ -33,3 +33,9 @@ type UpdateTransactionRequest struct { OccurredAt string `json:"occurred_at" validate:"omitempty"` Version int `json:"version" validate:"required,gt=0"` } + +type ReprocessTransactionRequest struct { + RawQuery string `json:"raw_query" validate:"required,max=500"` + Currency string `json:"currency" validate:"required,len=3"` + Timezone string `json:"timezone" validate:"omitempty"` +} diff --git a/internal/models/user_context.go b/internal/models/user_context.go index 4350418..8148f8a 100644 --- a/internal/models/user_context.go +++ b/internal/models/user_context.go @@ -1,5 +1,11 @@ package models +type PatchUserOnboardingContextRequest struct { + FinancialGoal *string `json:"financial_goal" validate:"omitempty,oneof=track_spending save_money clear_debt"` + MainChallenge *string `json:"main_challenge" validate:"omitempty,oneof=forget_to_log overspend no_savings_habit"` + ExperienceLevel *string `json:"experience_level" validate:"omitempty,oneof=first_time tried_quit used_regularly"` +} + type UpdateUserContextRequest struct { Context string `json:"context" validate:"max=1000"` } diff --git a/internal/msg/messages.go b/internal/msg/messages.go index fb01031..957eaab 100644 --- a/internal/msg/messages.go +++ b/internal/msg/messages.go @@ -76,6 +76,8 @@ const ( MsgKeysStored = "Encryption keys stored successfully." ErrPremiumRequired = "This feature requires a premium subscription." ErrInsightRateLimited = "Insight generation limit reached. Try again later." + ErrAIRateLimited = "Too many AI requests. Please slow down." + ErrSystemBusy = "System is busy. Please try again later." ErrFeatureRequiresPro = "This feature requires a Pro subscription." ErrPaymentFailed = "Payment failed. Please try again." ErrDebtNotFound = "Debt not found." @@ -88,7 +90,7 @@ const ( ErrFailedToLoadPlans = "Failed to load plans." ErrUserNotFound = "User not found." ErrNoEncryptionKeys = "No encryption keys found. Complete onboarding first." - ErrEncryptionKeysRequired = "Encryption keys are required before saving voice transactions." + ErrEncryptionKeysRequired = "encryption keys are required before saving voice transactions" ErrInvalidProductID = "Invalid product ID." ErrProductNotFound = "Product not found." ErrCheckoutCreationFailed = "Failed to create checkout." @@ -166,6 +168,8 @@ const ( CodeConfigKeyAlreadyExists = "CONFIG_KEY_ALREADY_EXISTS" CodePremiumRequired = "PREMIUM_REQUIRED" CodeInsightRateLimited = "INSIGHT_RATE_LIMITED" + CodeAIRateLimited = "AI_RATE_LIMITED" + CodeSystemBusy = "SYSTEM_BUSY" CodeUnsupportedAudioFormat = "UNSUPPORTED_AUDIO_FORMAT" CodeLowConfidenceParse = "LOW_CONFIDENCE_PARSE" CodeReauthRequired = "REAUTH_REQUIRED" @@ -327,6 +331,10 @@ func CodeForMessage(message string) string { return CodePremiumRequired case ErrInsightRateLimited: return CodeInsightRateLimited + case ErrAIRateLimited: + return CodeAIRateLimited + case ErrSystemBusy: + return CodeSystemBusy case ErrProviderUnavailable: return CodeProviderUnavailable case ErrUnsupportedAudioFormat: diff --git a/internal/services/debt_bundle_service.go b/internal/services/debt_bundle_service.go new file mode 100644 index 0000000..75da936 --- /dev/null +++ b/internal/services/debt_bundle_service.go @@ -0,0 +1,762 @@ +package services + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "strings" + "time" + + "numex-api/internal/db/queries" + "numex-api/internal/models" + "numex-api/internal/msg" + "numex-api/internal/utils" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" +) + +var ErrDebtBundleInvalid = errors.New("invalid debt bundle") +var ErrDebtBundleEncryptionKeysRequired = errors.New(msg.ErrEncryptionKeysRequired) +var errDebtBundleServiceNotReady = errors.New("debt bundle service is not ready") + +type DebtBundleService struct { + DB *pgxpool.Pool + Queries *queries.Queries +} + +type debtBundleEncryptedFields struct { + counterparty *string + merchant *string + note *string +} + +func NewDebtBundleService(db *pgxpool.Pool, q *queries.Queries) *DebtBundleService { + return &DebtBundleService{DB: db, Queries: q} +} + +func (s *DebtBundleService) Create(ctx context.Context, user queries.User, req models.DebtBundleEventRequest) (models.DebtBundleResponse, error) { + if err := ValidateDebtBundleRequest(req); err != nil { + return models.DebtBundleResponse{}, err + } + if err := s.ensureReady(); err != nil { + return models.DebtBundleResponse{}, err + } + + encrypted, err := buildDebtBundleEncryptedFields(user, req) + if err != nil { + return models.DebtBundleResponse{}, err + } + + tx, err := s.DB.Begin(ctx) + if err != nil { + return models.DebtBundleResponse{}, fmt.Errorf("begin debt bundle tx: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + q := queries.New(tx) + debt, err := s.createOrLoadDebt(ctx, q, user, req, encrypted) + if err != nil { + return models.DebtBundleResponse{}, err + } + + event, err := q.CreateDebtEvent(ctx, queries.CreateDebtEventParams{ + UserID: user.ID, + DebtID: debt.ID, + Kind: req.Kind, + Direction: debt.Direction, + AmountMinor: req.AmountMinor, + Currency: req.Currency, + ImpactAmountMinor: req.ImpactAmountMinor, + ImpactCurrency: req.ImpactCurrency, + ExchangeRate: numericFromString(req.ExchangeRate), + ExchangeRateDate: timestamptzFromRFC3339(req.ExchangeRateDate), + IncludeInAnalytics: req.IncludeInAnalytics, + Merchant: plaintextPtrUnlessEncrypted(req.Merchant, encrypted.merchant), + EncryptedMerchant: encrypted.merchant, + Note: plaintextPtrUnlessEncrypted(req.Note, encrypted.note), + EncryptedNote: encrypted.note, + Source: req.Source, + IdempotencyKey: optionalString(req.IdempotencyKey), + }) + if err != nil { + return models.DebtBundleResponse{}, fmt.Errorf("create debt event: %w", err) + } + + transactions, err := s.writeDebtBundleSplits(ctx, q, user, debt.ID, event.ID, req, encrypted) + if err != nil { + return models.DebtBundleResponse{}, err + } + + debt, err = recalculateDebt(ctx, q, user.ID, debt.ID, debt.AmountMinorOriginal) + if err != nil { + return models.DebtBundleResponse{}, err + } + + if err := tx.Commit(ctx); err != nil { + return models.DebtBundleResponse{}, fmt.Errorf("commit debt bundle tx: %w", err) + } + + return models.DebtBundleResponse{ + Debt: debtToBundleResponse(debt), + EventID: uuidToString(event.ID), + Transactions: transactions, + }, nil +} + +func (s *DebtBundleService) Update(ctx context.Context, user queries.User, eventID string, req models.DebtBundleEventRequest) (models.DebtBundleResponse, error) { + eventUUID, err := parseRequiredUUID(eventID) + if err != nil { + return models.DebtBundleResponse{}, ErrDebtBundleInvalid + } + if err := s.ensureReady(); err != nil { + return models.DebtBundleResponse{}, err + } + + tx, err := s.DB.Begin(ctx) + if err != nil { + return models.DebtBundleResponse{}, fmt.Errorf("begin debt bundle update tx: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + q := queries.New(tx) + event, err := q.GetDebtEventByID(ctx, queries.GetDebtEventByIDParams{ + UserID: user.ID, + ID: eventUUID, + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return models.DebtBundleResponse{}, ErrDebtBundleInvalid + } + return models.DebtBundleResponse{}, fmt.Errorf("get debt event: %w", err) + } + + if strings.TrimSpace(req.DebtID) == "" { + req.DebtID = uuidToString(event.DebtID) + } + if err := validateDebtBundleRequest(req, false); err != nil { + return models.DebtBundleResponse{}, err + } + if req.Kind != event.Kind || req.Direction != event.Direction || req.DebtID != uuidToString(event.DebtID) { + return models.DebtBundleResponse{}, ErrDebtBundleInvalid + } + + debt, err := q.GetDebtByID(ctx, queries.GetDebtByIDParams{ + ID: event.DebtID, + UserID: user.ID, + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return models.DebtBundleResponse{}, ErrDebtBundleInvalid + } + return models.DebtBundleResponse{}, fmt.Errorf("get debt: %w", err) + } + if debt.Direction != req.Direction || debt.Currency != req.ImpactCurrency || debt.Status == "archived" { + return models.DebtBundleResponse{}, ErrDebtBundleInvalid + } + + encrypted, err := buildDebtBundleEncryptedFields(user, req) + if err != nil { + return models.DebtBundleResponse{}, err + } + debt, err = q.UpdateDebtEncryptedFields(ctx, queries.UpdateDebtEncryptedFieldsParams{ + UserID: user.ID, + ID: debt.ID, + Counterparty: plaintextStringUnlessEncrypted(req.Counterparty, encrypted.counterparty), + EncryptedCounterparty: encrypted.counterparty, + Note: plaintextPtrUnlessEncrypted(req.Note, encrypted.note), + EncryptedNote: encrypted.note, + }) + if err != nil { + return models.DebtBundleResponse{}, fmt.Errorf("update debt encrypted fields: %w", err) + } + + if err := q.SoftDeleteDebtEventSplitsByEventID(ctx, queries.SoftDeleteDebtEventSplitsByEventIDParams{ + UserID: user.ID, + DebtEventID: event.ID, + }); err != nil { + return models.DebtBundleResponse{}, fmt.Errorf("soft delete debt event splits: %w", err) + } + if err := q.SoftDeleteTransactionsByDebtEventID(ctx, queries.SoftDeleteTransactionsByDebtEventIDParams{ + UserID: user.ID, + DebtEventID: event.ID, + }); err != nil { + return models.DebtBundleResponse{}, fmt.Errorf("soft delete debt bundle transactions: %w", err) + } + + event, err = q.UpdateDebtEvent(ctx, queries.UpdateDebtEventParams{ + UserID: user.ID, + ID: event.ID, + AmountMinor: req.AmountMinor, + Currency: req.Currency, + ImpactAmountMinor: req.ImpactAmountMinor, + ImpactCurrency: req.ImpactCurrency, + ExchangeRate: numericFromString(req.ExchangeRate), + ExchangeRateDate: timestamptzFromRFC3339(req.ExchangeRateDate), + IncludeInAnalytics: req.IncludeInAnalytics, + Merchant: plaintextPtrUnlessEncrypted(req.Merchant, encrypted.merchant), + EncryptedMerchant: encrypted.merchant, + Note: plaintextPtrUnlessEncrypted(req.Note, encrypted.note), + EncryptedNote: encrypted.note, + }) + if err != nil { + return models.DebtBundleResponse{}, fmt.Errorf("update debt event: %w", err) + } + + transactions, err := s.writeDebtBundleSplits(ctx, q, user, debt.ID, event.ID, req, encrypted) + if err != nil { + return models.DebtBundleResponse{}, err + } + + debt, err = recalculateDebt(ctx, q, user.ID, debt.ID, debt.AmountMinorOriginal) + if err != nil { + return models.DebtBundleResponse{}, err + } + + if err := tx.Commit(ctx); err != nil { + return models.DebtBundleResponse{}, fmt.Errorf("commit debt bundle update tx: %w", err) + } + + return models.DebtBundleResponse{ + Debt: debtToBundleResponse(debt), + EventID: uuidToString(event.ID), + Transactions: transactions, + }, nil +} + +func (s *DebtBundleService) Delete(ctx context.Context, user queries.User, eventID string) error { + eventUUID, err := parseRequiredUUID(eventID) + if err != nil { + return ErrDebtBundleInvalid + } + if err := s.ensureReady(); err != nil { + return err + } + + tx, err := s.DB.Begin(ctx) + if err != nil { + return fmt.Errorf("begin debt bundle delete tx: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + q := queries.New(tx) + event, err := q.GetDebtEventByID(ctx, queries.GetDebtEventByIDParams{ + UserID: user.ID, + ID: eventUUID, + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrDebtBundleInvalid + } + return fmt.Errorf("get debt event: %w", err) + } + debt, err := q.GetDebtByID(ctx, queries.GetDebtByIDParams{ + ID: event.DebtID, + UserID: user.ID, + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrDebtBundleInvalid + } + return fmt.Errorf("get debt: %w", err) + } + + if err := q.SoftDeleteTransactionsByDebtEventID(ctx, queries.SoftDeleteTransactionsByDebtEventIDParams{ + UserID: user.ID, + DebtEventID: event.ID, + }); err != nil { + return fmt.Errorf("soft delete debt bundle transactions: %w", err) + } + if err := q.SoftDeleteDebtEventSplitsByEventID(ctx, queries.SoftDeleteDebtEventSplitsByEventIDParams{ + UserID: user.ID, + DebtEventID: event.ID, + }); err != nil { + return fmt.Errorf("soft delete debt event splits: %w", err) + } + if err := q.SoftDeleteDebtEvent(ctx, queries.SoftDeleteDebtEventParams{ + UserID: user.ID, + ID: event.ID, + }); err != nil { + return fmt.Errorf("soft delete debt event: %w", err) + } + if _, err := recalculateDebt(ctx, q, user.ID, debt.ID, debt.AmountMinorOriginal); err != nil { + return err + } + + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("commit debt bundle delete tx: %w", err) + } + return nil +} + +func ValidateDebtBundleRequest(req models.DebtBundleEventRequest) error { + return validateDebtBundleRequest(req, true) +} + +func validateDebtBundleRequest(req models.DebtBundleEventRequest, rejectCreationDebtID bool) error { + if !oneOf(req.Kind, "creation", "repayment", "adjustment") || + !oneOf(req.Direction, "lent", "owed") || + !oneOf(req.Source, "voice", "chat_manual", "transaction_manual", "debt_screen", "convert") || + req.AmountMinor <= 0 || + req.ImpactAmountMinor <= 0 || + len(req.Currency) != 3 || + len(req.ImpactCurrency) != 3 || + strings.TrimSpace(req.Counterparty) == "" || + strings.TrimSpace(req.IdempotencyKey) == "" || + len(req.Splits) == 0 { + return ErrDebtBundleInvalid + } + + if req.Kind == "creation" && rejectCreationDebtID && strings.TrimSpace(req.DebtID) != "" { + return ErrDebtBundleInvalid + } + if req.Kind != "creation" && strings.TrimSpace(req.DebtID) == "" { + return ErrDebtBundleInvalid + } + if hasInvalidImplicitSourceRelink(req) { + return ErrDebtBundleInvalid + } + + var splitTotal int64 + for _, split := range req.Splits { + if split.AmountMinor <= 0 || len(split.Currency) != 3 || split.Currency != req.Currency { + return ErrDebtBundleInvalid + } + splitTotal += split.AmountMinor + } + if splitTotal != req.AmountMinor { + return ErrDebtBundleInvalid + } + + if req.ExchangeRate != nil && strings.TrimSpace(*req.ExchangeRate) != "" { + if !numericFromString(req.ExchangeRate).Valid { + return ErrDebtBundleInvalid + } + } + if req.ExchangeRateDate != "" && !timestamptzFromRFC3339(req.ExchangeRateDate).Valid { + return ErrDebtBundleInvalid + } + return nil +} + +func RecalculateRemaining(original int64, events []queries.DebtEvent) (remaining int64, overpaid int64, status string) { + remaining = original + for _, event := range events { + switch event.Kind { + case "repayment": + remaining -= event.ImpactAmountMinor + case "adjustment": + remaining += event.ImpactAmountMinor + } + } + if remaining <= 0 { + return 0, -remaining, "settled" + } + return remaining, 0, "open" +} + +func (s *DebtBundleService) writeDebtBundleSplits( + ctx context.Context, + q *queries.Queries, + user queries.User, + debtID pgtype.UUID, + eventID pgtype.UUID, + req models.DebtBundleEventRequest, + eventEncrypted debtBundleEncryptedFields, +) ([]models.DebtBundleTransactionResponse, error) { + categoryID, err := q.GetDebtBundleCategoryID(ctx, user.ID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrDebtBundleInvalid + } + return nil, fmt.Errorf("get debt bundle category: %w", err) + } + + txType := debtBundleTransactionType(req.Kind, req.Direction) + txSource := debtBundleTransactionSource(req.Source) + transactions := make([]models.DebtBundleTransactionResponse, 0, len(req.Splits)) + for _, split := range req.Splits { + balanceID, err := s.validateDebtBundleSplitBalance(ctx, q, user.ID, split) + if err != nil { + return nil, err + } + + encAmount, err := encryptDebtBundleValue(user, fmt.Sprintf("%d", split.AmountMinor), split.EncryptedAmount) + if err != nil { + return nil, err + } + if encAmount == nil { + return nil, ErrDebtBundleEncryptionKeysRequired + } + encMerchant := firstStringPtr(stringPtrIfNotEmpty(strings.TrimSpace(split.EncryptedMerchant)), eventEncrypted.merchant) + encNote := firstStringPtr(stringPtrIfNotEmpty(strings.TrimSpace(split.EncryptedNote)), eventEncrypted.note) + encRawQuery, err := encryptDebtBundleValue(user, "", split.EncryptedRawQuery) + if err != nil { + return nil, err + } + + transactionID := strings.TrimSpace(split.TransactionID) + if transactionID == "" && len(req.Splits) == 1 { + transactionID = strings.TrimSpace(req.SourceTransactionID) + } + + var txn queries.Transaction + if transactionID != "" { + parsedTransactionID, err := parseRequiredUUID(transactionID) + if err != nil { + return nil, ErrDebtBundleInvalid + } + txn, err = q.RelinkDebtBundleTransaction(ctx, queries.RelinkDebtBundleTransactionParams{ + UserID: user.ID, + ID: parsedTransactionID, + DebtID: debtID, + DebtEventID: eventID, + IncludeInAnalytics: req.IncludeInAnalytics, + EncryptedAmount: encAmount, + Merchant: plaintextPtrUnlessEncrypted(req.Merchant, encMerchant), + EncryptedMerchant: encMerchant, + Note: plaintextPtrUnlessEncrypted(req.Note, encNote), + EncryptedNote: encNote, + RawQuery: nil, + EncryptedRawQuery: encRawQuery, + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrDebtBundleInvalid + } + return nil, fmt.Errorf("relink debt bundle transaction: %w", err) + } + } else { + txn, err = q.CreateDebtBundleTransaction(ctx, queries.CreateDebtBundleTransactionParams{ + UserID: user.ID, + CategoryID: categoryID, + BalanceID: balanceID, + Type: txType, + Currency: split.Currency, + Merchant: plaintextPtrUnlessEncrypted(req.Merchant, encMerchant), + Note: plaintextPtrUnlessEncrypted(req.Note, encNote), + RawQuery: nil, + EncryptedAmount: encAmount, + EncryptedMerchant: encMerchant, + EncryptedNote: encNote, + EncryptedRawQuery: encRawQuery, + Source: txSource, + DebtID: debtID, + DebtEventID: eventID, + IncludeInAnalytics: req.IncludeInAnalytics, + }) + if err != nil { + return nil, fmt.Errorf("create debt bundle transaction: %w", err) + } + } + + if _, err := q.CreateDebtEventSplit(ctx, queries.CreateDebtEventSplitParams{ + UserID: user.ID, + DebtEventID: eventID, + TransactionID: txn.ID, + BalanceID: txn.BalanceID, + AmountMinor: split.AmountMinor, + Currency: split.Currency, + }); err != nil { + return nil, fmt.Errorf("create debt event split: %w", err) + } + + transactions = append(transactions, models.DebtBundleTransactionResponse{ + ID: uuidToString(txn.ID), + BalanceID: uuidStringPtr(txn.BalanceID), + AmountMinor: split.AmountMinor, + Currency: split.Currency, + IncludeInAnalytics: txn.IncludeInAnalytics, + }) + } + return transactions, nil +} + +func (s *DebtBundleService) validateDebtBundleSplitBalance(ctx context.Context, q *queries.Queries, userID pgtype.UUID, split models.DebtBundleSplitRequest) (pgtype.UUID, error) { + balanceID, err := parseOptionalUUID(split.BalanceID) + if err != nil { + return pgtype.UUID{}, ErrDebtBundleInvalid + } + if !balanceID.Valid { + return balanceID, nil + } + balance, err := q.GetBalanceByID(ctx, queries.GetBalanceByIDParams{ + ID: balanceID, + UserID: userID, + Lang: "en", + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return pgtype.UUID{}, ErrDebtBundleInvalid + } + return pgtype.UUID{}, fmt.Errorf("get debt bundle balance: %w", err) + } + if balance.Currency != split.Currency { + return pgtype.UUID{}, ErrDebtBundleInvalid + } + return balanceID, nil +} + +func (s *DebtBundleService) createOrLoadDebt(ctx context.Context, q *queries.Queries, user queries.User, req models.DebtBundleEventRequest, encrypted debtBundleEncryptedFields) (queries.Debt, error) { + if req.Kind == "creation" { + return q.CreateDebt(ctx, queries.CreateDebtParams{ + UserID: user.ID, + Counterparty: plaintextStringUnlessEncrypted(req.Counterparty, encrypted.counterparty), + EncryptedCounterparty: encrypted.counterparty, + Direction: req.Direction, + AmountMinorOriginal: req.ImpactAmountMinor, + Currency: req.ImpactCurrency, + Note: plaintextPtrUnlessEncrypted(req.Note, encrypted.note), + EncryptedNote: encrypted.note, + Source: req.Source, + }) + } + + debtID, err := parseRequiredUUID(req.DebtID) + if err != nil { + return queries.Debt{}, ErrDebtBundleInvalid + } + debt, err := q.GetDebtByID(ctx, queries.GetDebtByIDParams{ + ID: debtID, + UserID: user.ID, + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return queries.Debt{}, ErrDebtBundleInvalid + } + return queries.Debt{}, fmt.Errorf("get debt: %w", err) + } + if debt.Direction != req.Direction || debt.Currency != req.ImpactCurrency || debt.Status == "archived" { + return queries.Debt{}, ErrDebtBundleInvalid + } + return debt, nil +} + +func buildDebtBundleEncryptedFields(user queries.User, req models.DebtBundleEventRequest) (debtBundleEncryptedFields, error) { + encCounterparty, err := encryptDebtBundleValue(user, req.Counterparty, req.EncryptedCounterparty) + if err != nil { + return debtBundleEncryptedFields{}, err + } + encNote, err := encryptDebtBundleValue(user, req.Note, req.EncryptedNote) + if err != nil { + return debtBundleEncryptedFields{}, err + } + encMerchant, err := encryptDebtBundleValue(user, req.Merchant, req.EncryptedMerchant) + if err != nil { + return debtBundleEncryptedFields{}, err + } + return debtBundleEncryptedFields{ + counterparty: encCounterparty, + merchant: encMerchant, + note: encNote, + }, nil +} + +func encryptDebtBundleValue(user queries.User, plaintext, provided string) (*string, error) { + if strings.TrimSpace(provided) != "" { + value := strings.TrimSpace(provided) + return &value, nil + } + if strings.TrimSpace(plaintext) == "" { + return nil, nil + } + if !user.HasEncryptionKeys || user.PublicKey == nil { + return nil, ErrDebtBundleEncryptionKeysRequired + } + pubKey, err := base64.StdEncoding.DecodeString(*user.PublicKey) + if err != nil || len(pubKey) != 32 { + return nil, ErrDebtBundleEncryptionKeysRequired + } + enc, err := utils.EncryptForUser(pubKey, []byte(strings.TrimSpace(plaintext))) + if err != nil { + return nil, fmt.Errorf("encrypt debt bundle field: %w", err) + } + return &enc, nil +} + +func recalculateDebt(ctx context.Context, q *queries.Queries, userID pgtype.UUID, debtID pgtype.UUID, originalAmount int64) (queries.Debt, error) { + events, err := q.GetDebtEventsByDebtID(ctx, queries.GetDebtEventsByDebtIDParams{ + UserID: userID, + DebtID: debtID, + }) + if err != nil { + return queries.Debt{}, fmt.Errorf("get debt events: %w", err) + } + remaining, overpaid, status := RecalculateRemaining(originalAmount, events) + if err := q.UpdateDebtRemainingAndStatus(ctx, queries.UpdateDebtRemainingAndStatusParams{ + UserID: userID, + ID: debtID, + AmountMinorRemaining: remaining, + OverpaidAmountMinor: overpaid, + Status: status, + }); err != nil { + return queries.Debt{}, fmt.Errorf("update debt remaining: %w", err) + } + debt, err := q.GetDebtByID(ctx, queries.GetDebtByIDParams{ + ID: debtID, + UserID: userID, + }) + if err != nil { + return queries.Debt{}, fmt.Errorf("reload debt: %w", err) + } + return debt, nil +} + +func (s *DebtBundleService) ensureReady() error { + if s == nil || s.DB == nil { + return errDebtBundleServiceNotReady + } + return nil +} + +func debtBundleTransactionType(kind, direction string) string { + if kind == "repayment" { + if direction == "lent" { + return "income" + } + return "expense" + } + if direction == "owed" { + return "income" + } + return "expense" +} + +func debtBundleTransactionSource(source string) string { + if source == "voice" { + return "voice" + } + return "manual" +} + +func debtToBundleResponse(d queries.Debt) models.DebtResponse { + resp := models.DebtResponse{ + ID: uuidToString(d.ID), + Counterparty: d.Counterparty, + Direction: d.Direction, + AmountMinorOriginal: d.AmountMinorOriginal, + AmountMinorRemaining: d.AmountMinorRemaining, + OverpaidAmountMinor: d.OverpaidAmountMinor, + Currency: d.Currency, + Note: d.Note, + Status: d.Status, + Source: d.Source, + CreatedAt: d.CreatedAt.Time.UTC().Format(time.RFC3339), + } + return resp +} + +func optionalString(value string) *string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return nil + } + return &trimmed +} + +func plaintextPtrUnlessEncrypted(value string, encrypted *string) *string { + if encrypted != nil { + return nil + } + return optionalString(value) +} + +func plaintextStringUnlessEncrypted(value string, encrypted *string) string { + if encrypted != nil { + return "" + } + return strings.TrimSpace(value) +} + +func stringPtrIfNotEmpty(s string) *string { + if s == "" { + return nil + } + return &s +} + +func firstStringPtr(values ...*string) *string { + for _, value := range values { + if value != nil && strings.TrimSpace(*value) != "" { + trimmed := strings.TrimSpace(*value) + return &trimmed + } + } + return nil +} + +func hasInvalidImplicitSourceRelink(req models.DebtBundleEventRequest) bool { + if strings.TrimSpace(req.SourceTransactionID) == "" || len(req.Splits) <= 1 { + return false + } + for _, split := range req.Splits { + if strings.TrimSpace(split.TransactionID) == "" { + return true + } + } + return false +} + +func numericFromString(value *string) pgtype.Numeric { + if value == nil || strings.TrimSpace(*value) == "" { + return pgtype.Numeric{} + } + var numeric pgtype.Numeric + if err := numeric.Scan(strings.TrimSpace(*value)); err != nil { + return pgtype.Numeric{} + } + return numeric +} + +func timestamptzFromRFC3339(value string) pgtype.Timestamptz { + if strings.TrimSpace(value) == "" { + return pgtype.Timestamptz{} + } + parsed, err := time.Parse(time.RFC3339, strings.TrimSpace(value)) + if err != nil { + return pgtype.Timestamptz{} + } + return pgtype.Timestamptz{Time: parsed, Valid: true} +} + +func parseOptionalUUID(value string) (pgtype.UUID, error) { + if strings.TrimSpace(value) == "" { + return pgtype.UUID{}, nil + } + return parseRequiredUUID(value) +} + +func parseRequiredUUID(value string) (pgtype.UUID, error) { + parsed, err := uuid.Parse(strings.TrimSpace(value)) + if err != nil { + return pgtype.UUID{}, err + } + return pgtype.UUID{Bytes: parsed, Valid: true}, nil +} + +func uuidToString(value pgtype.UUID) string { + if !value.Valid { + return "" + } + return uuid.UUID(value.Bytes).String() +} + +func uuidStringPtr(value pgtype.UUID) *string { + if !value.Valid { + return nil + } + str := uuidToString(value) + return &str +} + +func oneOf(value string, allowed ...string) bool { + for _, item := range allowed { + if value == item { + return true + } + } + return false +} diff --git a/internal/services/debt_bundle_service_test.go b/internal/services/debt_bundle_service_test.go new file mode 100644 index 0000000..40e3989 --- /dev/null +++ b/internal/services/debt_bundle_service_test.go @@ -0,0 +1,152 @@ +package services + +import ( + "errors" + "testing" + + "numex-api/internal/db/queries" + "numex-api/internal/models" +) + +func TestRecalculateRemainingAutoSettlesAndTracksOverpay(t *testing.T) { + events := []queries.DebtEvent{ + {Kind: "repayment", ImpactAmountMinor: 6000}, + {Kind: "repayment", ImpactAmountMinor: 5000}, + } + + remaining, overpaid, status := RecalculateRemaining(10000, events) + + if remaining != 0 { + t.Fatalf("remaining = %d, want 0", remaining) + } + if overpaid != 1000 { + t.Fatalf("overpaid = %d, want 1000", overpaid) + } + if status != "settled" { + t.Fatalf("status = %s, want settled", status) + } +} + +func TestValidateDebtBundleRequestRejectsMissingCounterparty(t *testing.T) { + req := validDebtBundleRequest() + req.Counterparty = "" + err := ValidateDebtBundleRequest(req) + if !errors.Is(err, ErrDebtBundleInvalid) { + t.Fatalf("err = %v, want ErrDebtBundleInvalid", err) + } +} + +func TestValidateDebtBundleRequestRejectsSplitMismatch(t *testing.T) { + req := validDebtBundleRequest() + req.Splits = []models.DebtBundleSplitRequest{ + {AmountMinor: 400, Currency: "USD"}, + {AmountMinor: 500, Currency: "USD"}, + } + err := ValidateDebtBundleRequest(req) + if !errors.Is(err, ErrDebtBundleInvalid) { + t.Fatalf("err = %v, want ErrDebtBundleInvalid", err) + } +} + +func TestValidateDebtBundleRequestRequiresDebtIDForRepayment(t *testing.T) { + req := validDebtBundleRequest() + req.Kind = "repayment" + err := ValidateDebtBundleRequest(req) + if !errors.Is(err, ErrDebtBundleInvalid) { + t.Fatalf("err = %v, want ErrDebtBundleInvalid", err) + } +} + +func TestValidateDebtBundleRequestAllowsEncryptedPayload(t *testing.T) { + req := validDebtBundleRequest() + req.EncryptedCounterparty = "enc-counterparty" + req.Splits[0].EncryptedAmount = "enc-amount" + if err := ValidateDebtBundleRequest(req); err != nil { + t.Fatalf("ValidateDebtBundleRequest() error = %v", err) + } +} + +func TestValidateDebtBundleRequestAllowsConvertSourceTransaction(t *testing.T) { + req := validDebtBundleRequest() + req.Source = "convert" + req.SourceTransactionID = "8fdc1e32-5f47-4a66-bc81-d20179946d3b" + if err := ValidateDebtBundleRequest(req); err != nil { + t.Fatalf("ValidateDebtBundleRequest() error = %v", err) + } +} + +func TestValidateDebtBundleRequestRejectsConvertWithMultipleImplicitSplits(t *testing.T) { + req := validDebtBundleRequest() + req.Source = "convert" + req.SourceTransactionID = "8fdc1e32-5f47-4a66-bc81-d20179946d3b" + req.AmountMinor = 1000 + req.Splits = []models.DebtBundleSplitRequest{ + {AmountMinor: 500, Currency: "USD"}, + {AmountMinor: 500, Currency: "USD"}, + } + err := ValidateDebtBundleRequest(req) + if !errors.Is(err, ErrDebtBundleInvalid) { + t.Fatalf("err = %v, want ErrDebtBundleInvalid", err) + } +} + +func TestEncryptDebtBundleValueRejectsMissingKeys(t *testing.T) { + _, err := encryptDebtBundleValue(queries.User{}, "Ali", "") + if !errors.Is(err, ErrDebtBundleEncryptionKeysRequired) { + t.Fatalf("err = %v, want encryption keys required", err) + } +} + +func TestDebtBundleTransactionTypeMirrorsCashDirection(t *testing.T) { + tests := []struct { + name string + kind string + direction string + want string + }{ + {name: "creation lent", kind: "creation", direction: "lent", want: "expense"}, + {name: "creation owed", kind: "creation", direction: "owed", want: "income"}, + {name: "repayment lent", kind: "repayment", direction: "lent", want: "income"}, + {name: "repayment owed", kind: "repayment", direction: "owed", want: "expense"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := debtBundleTransactionType(tt.kind, tt.direction); got != tt.want { + t.Fatalf("type = %s, want %s", got, tt.want) + } + }) + } +} + +func TestDebtBundleTransactionTypeForUpdateRepayment(t *testing.T) { + if got := debtBundleTransactionType("repayment", "lent"); got != "income" { + t.Fatalf("type = %s, want income", got) + } +} + +func TestDebtBundleTransactionSourceMapsExtendedSourcesToManual(t *testing.T) { + if got := debtBundleTransactionSource("voice"); got != "voice" { + t.Fatalf("voice source = %s, want voice", got) + } + if got := debtBundleTransactionSource("debt_screen"); got != "manual" { + t.Fatalf("debt_screen source = %s, want manual", got) + } +} + +func validDebtBundleRequest() models.DebtBundleEventRequest { + return models.DebtBundleEventRequest{ + Kind: "creation", + Direction: "lent", + Counterparty: "Ali", + AmountMinor: 1000, + Currency: "USD", + ImpactAmountMinor: 1000, + ImpactCurrency: "USD", + Source: "debt_screen", + IdempotencyKey: "key-1", + Splits: []models.DebtBundleSplitRequest{ + {AmountMinor: 1000, Currency: "USD"}, + }, + } +} diff --git a/internal/services/limits.go b/internal/services/limits.go new file mode 100644 index 0000000..35d7559 --- /dev/null +++ b/internal/services/limits.go @@ -0,0 +1,57 @@ +package services + +import ( + "context" + "time" + + "github.com/redis/go-redis/v9" +) + +type CounterStore interface { + Incr(ctx context.Context, key string) (int64, error) + Expire(ctx context.Context, key string, ttl time.Duration) error +} + +type AdmissionService struct { + store CounterStore +} + +func NewAdmissionService(store CounterStore) *AdmissionService { + return &AdmissionService{ + store: store, + } +} + +func (s *AdmissionService) AllowUserWindow(ctx context.Context, key string, limit int64, window time.Duration) (bool, int64, error) { + if limit <= 0 { + return true, 0, nil + } + + count, err := s.store.Incr(ctx, key) + if err != nil { + return false, 0, err + } + if count == 1 && window > 0 { + if err := s.store.Expire(ctx, key, window); err != nil { + return false, 0, err + } + } + + return count <= limit, count, nil +} + +type RedisCounterStore struct { + client *redis.Client +} + +func NewRedisCounterStore(client *redis.Client) *RedisCounterStore { + return &RedisCounterStore{client: client} +} + +func (s *RedisCounterStore) Incr(ctx context.Context, key string) (int64, error) { + return s.client.Incr(ctx, key).Result() +} + +func (s *RedisCounterStore) Expire(ctx context.Context, key string, ttl time.Duration) error { + return s.client.Expire(ctx, key, ttl).Err() +} diff --git a/internal/services/limits_test.go b/internal/services/limits_test.go new file mode 100644 index 0000000..67d751a --- /dev/null +++ b/internal/services/limits_test.go @@ -0,0 +1,70 @@ +package services + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type fakeCounterStore struct { + counts map[string]int64 + expirations map[string]time.Duration + incrErr error + expireErr error +} + +func (f *fakeCounterStore) Incr(_ context.Context, key string) (int64, error) { + if f.incrErr != nil { + return 0, f.incrErr + } + if f.counts == nil { + f.counts = make(map[string]int64) + } + f.counts[key]++ + return f.counts[key], nil +} + +func (f *fakeCounterStore) Expire(_ context.Context, key string, ttl time.Duration) error { + if f.expireErr != nil { + return f.expireErr + } + if f.expirations == nil { + f.expirations = make(map[string]time.Duration) + } + f.expirations[key] = ttl + return nil +} + +func TestAllowUserWindowSetsTTLOnFirstHit(t *testing.T) { + store := &fakeCounterStore{} + svc := NewAdmissionService(store) + + allowed, count, err := svc.AllowUserWindow(context.Background(), "voice:user-1", 3, time.Minute) + require.NoError(t, err) + assert.True(t, allowed) + assert.Equal(t, int64(1), count) + assert.Equal(t, time.Minute, store.expirations["voice:user-1"]) +} + +func TestAllowUserWindowRejectsOverLimit(t *testing.T) { + store := &fakeCounterStore{counts: map[string]int64{"voice:user-1": 3}} + svc := NewAdmissionService(store) + + allowed, count, err := svc.AllowUserWindow(context.Background(), "voice:user-1", 3, time.Minute) + require.NoError(t, err) + assert.False(t, allowed) + assert.Equal(t, int64(4), count) +} + +func TestAllowUserWindowPropagatesStoreError(t *testing.T) { + svc := NewAdmissionService(&fakeCounterStore{incrErr: errors.New("redis down")}) + + allowed, count, err := svc.AllowUserWindow(context.Background(), "voice:user-1", 3, time.Minute) + require.Error(t, err) + assert.False(t, allowed) + assert.Zero(t, count) +} diff --git a/internal/services/provider_limits.go b/internal/services/provider_limits.go new file mode 100644 index 0000000..e0b79cf --- /dev/null +++ b/internal/services/provider_limits.go @@ -0,0 +1,108 @@ +package services + +import ( + "context" + "reflect" + "time" + + "github.com/redis/go-redis/v9" +) + +type ProviderCircuitBreaker struct { + redis providerRedis +} + +type providerRedis interface { + Exists(ctx context.Context, keys ...string) *redis.IntCmd + Set(ctx context.Context, key string, value interface{}, expiration time.Duration) *redis.StatusCmd + Del(ctx context.Context, keys ...string) *redis.IntCmd + Incr(ctx context.Context, key string) *redis.IntCmd + Decr(ctx context.Context, key string) *redis.IntCmd + Expire(ctx context.Context, key string, expiration time.Duration) *redis.BoolCmd +} + +func NewProviderCircuitBreaker(redisClient providerRedis) *ProviderCircuitBreaker { + return &ProviderCircuitBreaker{redis: redisClient} +} + +func (b *ProviderCircuitBreaker) IsOpen(ctx context.Context, provider string) (bool, error) { + if b == nil || isNilProviderRedis(b.redis) { + return false, nil + } + result, err := b.redis.Exists(ctx, breakerKey(provider)).Result() + if err != nil { + return false, err + } + return result > 0, nil +} + +func (b *ProviderCircuitBreaker) Open(ctx context.Context, provider string, ttl time.Duration) error { + if b == nil || isNilProviderRedis(b.redis) { + return nil + } + if ttl <= 0 { + ttl = time.Minute + } + return b.redis.Set(ctx, breakerKey(provider), "open", ttl).Err() +} + +func (b *ProviderCircuitBreaker) Close(ctx context.Context, provider string) error { + if b == nil || isNilProviderRedis(b.redis) { + return nil + } + return b.redis.Del(ctx, breakerKey(provider)).Err() +} + +func isNilProviderRedis(redisClient providerRedis) bool { + if redisClient == nil { + return true + } + value := reflect.ValueOf(redisClient) + switch value.Kind() { + case reflect.Interface, reflect.Pointer, reflect.Map, reflect.Slice, reflect.Func, reflect.Chan: + return value.IsNil() + default: + return false + } +} + +func breakerKey(provider string) string { + return "provider_circuit:" + provider +} + +type ProviderConcurrencyGate struct { + redis providerRedis +} + +func NewProviderConcurrencyGate(redisClient providerRedis) *ProviderConcurrencyGate { + return &ProviderConcurrencyGate{redis: redisClient} +} + +func (g *ProviderConcurrencyGate) Acquire(ctx context.Context, provider string, limit int, ttl time.Duration) (func(), error) { + if g == nil || isNilProviderRedis(g.redis) || limit <= 0 { + return func() {}, nil + } + key := providerConcurrencyKey(provider) + count, err := g.redis.Incr(ctx, key).Result() + if err != nil { + return nil, err + } + if ttl > 0 { + if err := g.redis.Expire(ctx, key, ttl).Err(); err != nil { + return nil, err + } + } + if count > int64(limit) { + if err := g.redis.Decr(ctx, key).Err(); err != nil { + return nil, err + } + return nil, nil + } + return func() { + _ = g.redis.Decr(context.Background(), key).Err() + }, nil +} + +func providerConcurrencyKey(provider string) string { + return "provider_concurrency:" + provider +} diff --git a/internal/services/provider_limits_test.go b/internal/services/provider_limits_test.go new file mode 100644 index 0000000..c99c6c0 --- /dev/null +++ b/internal/services/provider_limits_test.go @@ -0,0 +1,126 @@ +package services + +import ( + "context" + "testing" + "time" + + "github.com/redis/go-redis/v9" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type fakeProviderRedis struct { + keys map[string]string + counters map[string]int64 +} + +func (f *fakeProviderRedis) Exists(_ context.Context, keys ...string) *redis.IntCmd { + cmd := redis.NewIntCmd(context.Background()) + var count int64 + for _, key := range keys { + if _, ok := f.keys[key]; ok { + count++ + } + } + cmd.SetVal(count) + return cmd +} + +func (f *fakeProviderRedis) Set(_ context.Context, key string, value interface{}, _ time.Duration) *redis.StatusCmd { + if f.keys == nil { + f.keys = make(map[string]string) + } + f.keys[key] = value.(string) + cmd := redis.NewStatusCmd(context.Background()) + cmd.SetVal("OK") + return cmd +} + +func (f *fakeProviderRedis) Del(_ context.Context, keys ...string) *redis.IntCmd { + if f.keys == nil { + f.keys = make(map[string]string) + } + var deleted int64 + for _, key := range keys { + if _, ok := f.keys[key]; ok { + delete(f.keys, key) + deleted++ + } + } + cmd := redis.NewIntCmd(context.Background()) + cmd.SetVal(deleted) + return cmd +} + +func (f *fakeProviderRedis) Incr(_ context.Context, key string) *redis.IntCmd { + if f.counters == nil { + f.counters = make(map[string]int64) + } + f.counters[key]++ + cmd := redis.NewIntCmd(context.Background()) + cmd.SetVal(f.counters[key]) + return cmd +} + +func (f *fakeProviderRedis) Decr(_ context.Context, key string) *redis.IntCmd { + if f.counters == nil { + f.counters = make(map[string]int64) + } + f.counters[key]-- + cmd := redis.NewIntCmd(context.Background()) + cmd.SetVal(f.counters[key]) + return cmd +} + +func (f *fakeProviderRedis) Expire(_ context.Context, _ string, _ time.Duration) *redis.BoolCmd { + cmd := redis.NewBoolCmd(context.Background()) + cmd.SetVal(true) + return cmd +} + +func TestProviderCircuitBreakerOpenClose(t *testing.T) { + breaker := NewProviderCircuitBreaker(&fakeProviderRedis{}) + + open, err := breaker.IsOpen(context.Background(), "gemini") + require.NoError(t, err) + assert.False(t, open) + + require.NoError(t, breaker.Open(context.Background(), "gemini", time.Minute)) + open, err = breaker.IsOpen(context.Background(), "gemini") + require.NoError(t, err) + assert.True(t, open) + + require.NoError(t, breaker.Close(context.Background(), "gemini")) + open, err = breaker.IsOpen(context.Background(), "gemini") + require.NoError(t, err) + assert.False(t, open) +} + +func TestProviderConcurrencyGateAcquireRelease(t *testing.T) { + redisClient := &fakeProviderRedis{} + gate := NewProviderConcurrencyGate(redisClient) + + release, err := gate.Acquire(context.Background(), "gemini", 2, time.Minute) + require.NoError(t, err) + require.NotNil(t, release) + + release() + assert.Equal(t, int64(0), redisClient.counters[providerConcurrencyKey("gemini")]) +} + +func TestProviderConcurrencyGateRejectsWhenLimitExceeded(t *testing.T) { + redisClient := &fakeProviderRedis{} + gate := NewProviderConcurrencyGate(redisClient) + + release, err := gate.Acquire(context.Background(), "gemini", 1, time.Minute) + require.NoError(t, err) + require.NotNil(t, release) + + release2, err := gate.Acquire(context.Background(), "gemini", 1, time.Minute) + require.NoError(t, err) + assert.Nil(t, release2) + + release() + assert.Equal(t, int64(0), redisClient.counters[providerConcurrencyKey("gemini")]) +} diff --git a/internal/storage/local.go b/internal/storage/local.go new file mode 100644 index 0000000..f478dfb --- /dev/null +++ b/internal/storage/local.go @@ -0,0 +1,54 @@ +package storage + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" +) + +type LocalStore struct { + root string +} + +func NewLocalStore(root string) *LocalStore { + return &LocalStore{root: root} +} + +func (s *LocalStore) Put(_ context.Context, key string, payload []byte) error { + fullPath := filepath.Join(s.root, filepath.Clean(key)) + if err := os.MkdirAll(filepath.Dir(fullPath), 0o750); err != nil { + return fmt.Errorf("mkdir object path: %w", err) + } + if err := os.WriteFile(fullPath, payload, 0o600); err != nil { + return fmt.Errorf("write object: %w", err) + } + return nil +} + +func (s *LocalStore) Get(_ context.Context, key string) ([]byte, error) { + root, err := os.OpenRoot(s.root) + if err != nil { + return nil, fmt.Errorf("open storage root: %w", err) + } + defer func() { _ = root.Close() }() + f, err := root.Open(filepath.Clean(key)) + if err != nil { + return nil, fmt.Errorf("read object: %w", err) + } + defer func() { _ = f.Close() }() + payload, err := io.ReadAll(f) + if err != nil { + return nil, fmt.Errorf("read object body: %w", err) + } + return payload, nil +} + +func (s *LocalStore) Delete(_ context.Context, key string) error { + fullPath := filepath.Join(s.root, filepath.Clean(key)) + if err := os.Remove(fullPath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("delete object: %w", err) + } + return nil +} diff --git a/internal/storage/local_test.go b/internal/storage/local_test.go new file mode 100644 index 0000000..bb48eef --- /dev/null +++ b/internal/storage/local_test.go @@ -0,0 +1,49 @@ +package storage + +import ( + "context" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLocalStoreRoundTrip(t *testing.T) { + root := t.TempDir() + store := NewLocalStore(root) + + err := store.Put(context.Background(), "voice/2026/04/15/test.ogg", []byte("hello")) + require.NoError(t, err) + + payload, err := store.Get(context.Background(), "voice/2026/04/15/test.ogg") + require.NoError(t, err) + assert.Equal(t, []byte("hello"), payload) + + err = store.Delete(context.Background(), "voice/2026/04/15/test.ogg") + require.NoError(t, err) + _, err = store.Get(context.Background(), "voice/2026/04/15/test.ogg") + require.Error(t, err) +} + +func TestNewObjectStoreCreatesLocalTempStorage(t *testing.T) { + root := t.TempDir() + + store, err := NewObjectStore(root) + require.NoError(t, err) + + err = store.Put(context.Background(), "voice/2026/04/15/test.ogg", []byte("hello")) + require.NoError(t, err) + + payload, err := store.Get(context.Background(), "voice/2026/04/15/test.ogg") + require.NoError(t, err) + assert.Equal(t, []byte("hello"), payload) +} + +func TestVoiceObjectKeyPreservesExtension(t *testing.T) { + key := VoiceObjectKey(time.Date(2026, 4, 15, 8, 0, 0, 0, time.UTC), "recording.OGG") + assert.True(t, strings.HasPrefix(key, "voice/2026/04/15/")) + assert.Equal(t, ".ogg", filepath.Ext(key)) +} diff --git a/internal/storage/object_key.go b/internal/storage/object_key.go new file mode 100644 index 0000000..4c2ab9c --- /dev/null +++ b/internal/storage/object_key.go @@ -0,0 +1,25 @@ +package storage + +import ( + "fmt" + "path" + "strings" + "time" + + "github.com/google/uuid" +) + +func VoiceObjectKey(now time.Time, filename string) string { + ext := path.Ext(filename) + if ext == "" { + ext = ".bin" + } + ext = strings.ToLower(ext) + return fmt.Sprintf("voice/%04d/%02d/%02d/%s%s", + now.UTC().Year(), + now.UTC().Month(), + now.UTC().Day(), + uuid.NewString(), + ext, + ) +} diff --git a/internal/storage/s3.go b/internal/storage/s3.go new file mode 100644 index 0000000..a58684b --- /dev/null +++ b/internal/storage/s3.go @@ -0,0 +1,87 @@ +package storage + +import ( + "bytes" + "context" + "fmt" + "io" + + "github.com/minio/minio-go/v7" + "github.com/minio/minio-go/v7/pkg/credentials" +) + +type S3Config struct { + Endpoint string + Bucket string + AccessKey string + SecretKey string + UseSSL bool + Region string +} + +type S3Store struct { + client *minio.Client + bucket string +} + +func NewS3Store(ctx context.Context, cfg S3Config) (*S3Store, error) { + if cfg.Endpoint == "" { + return nil, fmt.Errorf("s3 endpoint is required") + } + if cfg.Bucket == "" { + return nil, fmt.Errorf("s3 bucket is required") + } + client, err := minio.New(cfg.Endpoint, &minio.Options{ + Creds: credentials.NewStaticV4(cfg.AccessKey, cfg.SecretKey, ""), + Secure: cfg.UseSSL, + Region: cfg.Region, + }) + if err != nil { + return nil, fmt.Errorf("create s3 client: %w", err) + } + + exists, err := client.BucketExists(ctx, cfg.Bucket) + if err != nil { + return nil, fmt.Errorf("check s3 bucket: %w", err) + } + if !exists { + if err := client.MakeBucket(ctx, cfg.Bucket, minio.MakeBucketOptions{ + Region: cfg.Region, + }); err != nil { + return nil, fmt.Errorf("create s3 bucket: %w", err) + } + } + + return &S3Store{ + client: client, + bucket: cfg.Bucket, + }, nil +} + +func (s *S3Store) Put(ctx context.Context, key string, payload []byte) error { + _, err := s.client.PutObject(ctx, s.bucket, key, bytes.NewReader(payload), int64(len(payload)), minio.PutObjectOptions{}) + if err != nil { + return fmt.Errorf("put object: %w", err) + } + return nil +} + +func (s *S3Store) Get(ctx context.Context, key string) ([]byte, error) { + obj, err := s.client.GetObject(ctx, s.bucket, key, minio.GetObjectOptions{}) + if err != nil { + return nil, fmt.Errorf("get object: %w", err) + } + defer func() { _ = obj.Close() }() + payload, err := io.ReadAll(obj) + if err != nil { + return nil, fmt.Errorf("read object: %w", err) + } + return payload, nil +} + +func (s *S3Store) Delete(ctx context.Context, key string) error { + if err := s.client.RemoveObject(ctx, s.bucket, key, minio.RemoveObjectOptions{}); err != nil { + return fmt.Errorf("delete object: %w", err) + } + return nil +} diff --git a/internal/storage/storage.go b/internal/storage/storage.go new file mode 100644 index 0000000..dffea8e --- /dev/null +++ b/internal/storage/storage.go @@ -0,0 +1,21 @@ +package storage + +import ( + "context" + "fmt" +) + +type ObjectStore interface { + Put(ctx context.Context, key string, payload []byte) error + Get(ctx context.Context, key string) ([]byte, error) + Delete(ctx context.Context, key string) error +} + +// NewObjectStore creates a local temp store rooted at root. +// Audio never leaves the machine — same-host API+worker is required. +func NewObjectStore(root string) (ObjectStore, error) { + if root == "" { + return nil, fmt.Errorf("local temp storage root is required") + } + return NewLocalStore(root), nil +} diff --git a/internal/utils/insight_parser.go b/internal/utils/insight_parser.go index 55fc0d7..8a45e51 100644 --- a/internal/utils/insight_parser.go +++ b/internal/utils/insight_parser.go @@ -1,6 +1,10 @@ package utils -import "encoding/json" +import ( + "encoding/json" + "errors" + "strings" +) type insightJSON struct { Title string `json:"title"` @@ -13,5 +17,10 @@ func ParseInsightJSON(raw string) (string, string, error) { if err := json.Unmarshal([]byte(raw), &parsed); err != nil { return "", "", err } - return parsed.Title, parsed.Body, nil + title := strings.TrimSpace(parsed.Title) + body := strings.TrimSpace(parsed.Body) + if title == "" || body == "" { + return "", "", errors.New("insight json missing title or body") + } + return title, body, nil } diff --git a/internal/utils/insight_parser_test.go b/internal/utils/insight_parser_test.go index 9ddb0e0..9c8bbd4 100644 --- a/internal/utils/insight_parser_test.go +++ b/internal/utils/insight_parser_test.go @@ -22,15 +22,17 @@ func TestParseInsightJSON_Invalid(t *testing.T) { } func TestParseInsightJSON_Empty(t *testing.T) { - title, body, err := utils.ParseInsightJSON(`{"title": "", "body": ""}`) - assert.NoError(t, err) - assert.Empty(t, title) - assert.Empty(t, body) + _, _, err := utils.ParseInsightJSON(`{"title": "", "body": "Valid body"}`) + assert.Error(t, err) + + _, _, err = utils.ParseInsightJSON(`{"title": "Valid title", "body": ""}`) + assert.Error(t, err) } func TestParseInsightJSON_MissingFields(t *testing.T) { - title, body, err := utils.ParseInsightJSON(`{}`) - assert.NoError(t, err) - assert.Empty(t, title) - assert.Empty(t, body) + _, _, err := utils.ParseInsightJSON(`{"title": "Only title"}`) + assert.Error(t, err) + + _, _, err = utils.ParseInsightJSON(`{"body": "Only body"}`) + assert.Error(t, err) } diff --git a/internal/workers/billing.go b/internal/workers/billing.go deleted file mode 100644 index d0f8fdd..0000000 --- a/internal/workers/billing.go +++ /dev/null @@ -1,421 +0,0 @@ -package workers - -import ( - "context" - "fmt" - "log/slog" - "numex-api/internal/clients" - "numex-api/internal/db/queries" - "numex-api/internal/utils" - "sync" - "time" - - "github.com/jackc/pgx/v5/pgtype" - "github.com/jackc/pgx/v5/pgxpool" -) - -// BillingWorker runs periodic billing jobs for Payme subscriptions. -type BillingWorker struct { - db *pgxpool.Pool - queries *queries.Queries - payme *clients.PaymeClient - email *clients.EmailService - logger *slog.Logger -} - -// NewBillingWorker creates a new BillingWorker. -func NewBillingWorker(db *pgxpool.Pool, payme *clients.PaymeClient, email *clients.EmailService) *BillingWorker { - return &BillingWorker{ - db: db, - queries: queries.New(db), - payme: payme, - email: email, - logger: slog.Default(), - } -} - -func (w *BillingWorker) Start(ctx context.Context, interval time.Duration, wg *sync.WaitGroup) { - wg.Go(func() { - for { - utils.WithRecover("billing", func() { - w.runLoop(ctx, interval) - }) - - if ctx.Err() != nil { - return - } - w.logger.Info("billing worker restarting after panic") - } - }) - w.logger.Info("billing worker started", "interval", interval) -} - -func (w *BillingWorker) runLoop(ctx context.Context, interval time.Duration) { - ticker := time.NewTicker(interval) - defer ticker.Stop() - - w.runCycle(ctx) - - for { - select { - case <-ctx.Done(): - w.logger.Info("billing worker stopping") - return - case <-ticker.C: - w.runCycle(ctx) - } - } -} - -func (w *BillingWorker) runCycle(ctx context.Context) { - // 1. Claim pending billing jobs - jobs, err := w.queries.ClaimPendingBillingJobs(ctx) - if err != nil { - w.logger.Error("failed to claim billing jobs", "error", err) - return - } - - if len(jobs) == 0 { - return - } - - w.logger.Info("processing billing jobs", "count", len(jobs)) - - for _, job := range jobs { - w.processJob(ctx, job) - } - - // 2. Expire any Payme subscriptions that ran out of grace time. - w.expirePastDueSubscriptions(ctx) - - // 2. Check for expiring subscriptions (3 days before expiry) - w.checkExpiringSubscriptions(ctx) -} - -func (w *BillingWorker) processJob(ctx context.Context, job queries.BillingJob) { - if w.payme == nil { - w.logger.Warn("billing worker: Payme client not configured, skipping job", - "job_id", job.ID) - if err := w.queries.FailBillingJob(ctx, queries.FailBillingJobParams{ - ID: job.ID, - ErrorMessage: strPtr("payme client not configured"), - }); err != nil { - w.logger.Error("failed to mark billing job as failed", "job_id", job.ID, "error", err) - } - return - } - // Get subscription - sub, err := w.queries.GetActiveSubscriptionByUserID(ctx, job.UserID) - if err != nil { - w.logger.Error("billing job: subscription not found", "job_id", job.ID, "error", err) - if err := w.queries.FailBillingJob(ctx, queries.FailBillingJobParams{ - ID: job.ID, - ErrorMessage: strPtr("subscription not found"), - }); err != nil { - w.logger.Error("failed to mark billing job as failed", "job_id", job.ID, "error", err) - } - return - } - - // Only process Payme jobs - if sub.Provider != "payme" { - if err := w.queries.CompleteBillingJob(ctx, job.ID); err != nil { - w.logger.Error("failed to complete billing job", "job_id", job.ID, "error", err) - } - return - } - - // Check if subscription was cancelled - if sub.CancelAtPeriodEnd { - w.logger.Info("billing job: subscription cancelled, skipping", "job_id", job.ID) - if err := w.queries.CompleteBillingJob(ctx, job.ID); err != nil { - w.logger.Error("failed to complete billing job", "job_id", job.ID, "error", err) - } - - // Expire the subscription - if err := w.queries.UpdateSubscriptionStatus(ctx, queries.UpdateSubscriptionStatusParams{ - ID: sub.ID, - Status: "expired", - }); err != nil { - w.logger.Error("failed to expire subscription", "sub_id", sub.ID, "error", err) - } - - // Downgrade to free - if err := w.queries.UpsertEntitlement(ctx, queries.UpsertEntitlementParams{ - UserID: sub.UserID, - PlanID: "free", - ActiveUntil: pgtype.Timestamptz{Time: time.Now(), Valid: true}, - }); err != nil { - w.logger.Error("failed to downgrade entitlement", "user_id", sub.UserID, "error", err) - } - recordDowngradeForWorker(ctx, w.queries, sub.UserID, "subscription_canceled") - return - } - - if w.payme == nil { - if err := w.queries.FailBillingJob(ctx, queries.FailBillingJobParams{ - ID: job.ID, - ErrorMessage: strPtr("payme client not configured"), - }); err != nil { - w.logger.Error("failed to mark billing job as failed", "job_id", job.ID, "error", err) - } - return - } - - // Get active card - card, err := w.queries.GetActivePaymeCardByUserID(ctx, job.UserID) - if err != nil { - if err := w.queries.FailBillingJob(ctx, queries.FailBillingJobParams{ - ID: job.ID, - ErrorMessage: strPtr("no active card found"), - }); err != nil { - w.logger.Error("failed to mark billing job as failed", "job_id", job.ID, "error", err) - } - return - } - - // Create receipt and attempt payment - orderID := fmt.Sprintf("%x-%d", job.SubscriptionID.Bytes, time.Now().Unix()) - receiptID, err := w.payme.ReceiptsCreate(job.AmountMinor, orderID, card.CardToken) - if err != nil { - w.logger.Error("billing: receipt creation failed", "job_id", job.ID, "error", err) - if err := w.queries.FailBillingJob(ctx, queries.FailBillingJobParams{ - ID: job.ID, - ErrorMessage: strPtr(sanitizeBillingError(err)), - }); err != nil { - w.logger.Error("failed to mark billing job as failed", "job_id", job.ID, "error", err) - } - return - } - - if err := w.payme.ReceiptsPay(receiptID, card.CardToken); err != nil { - w.logger.Error("billing: payment failed", "job_id", job.ID, "error", err) - if err := w.queries.FailBillingJob(ctx, queries.FailBillingJobParams{ - ID: job.ID, - ErrorMessage: strPtr(sanitizeBillingError(err)), - }); err != nil { - w.logger.Error("failed to mark billing job as failed", "job_id", job.ID, "error", err) - } - - if !sub.GraceUntil.Valid || !time.Now().Before(sub.GraceUntil.Time) { - w.expireSubscription(ctx, sub, "payment_grace_expired") - return - } - - if err := w.queries.UpdateSubscriptionStatus(ctx, queries.UpdateSubscriptionStatusParams{ - ID: sub.ID, - Status: "past_due", - }); err != nil { - w.logger.Error("failed to mark subscription as past_due", "sub_id", sub.ID, "error", err) - } - - pastDueSince := sub.PastDueSince - if !pastDueSince.Valid { - pastDueSince = pgtype.Timestamptz{Time: time.Now(), Valid: true} - } - if err := w.queries.UpdateSubscriptionBillingState(ctx, queries.UpdateSubscriptionBillingStateParams{ - ID: sub.ID, - BillingAnchorDay: sub.BillingAnchorDay, - BillingTimezone: sub.BillingTimezone, - GraceDays: sub.GraceDays, - GraceUntil: sub.GraceUntil, - PastDueSince: pastDueSince, - }); err != nil { - w.logger.Error("failed to persist past-due billing state", "sub_id", sub.ID, "error", err) - } - if err := w.queries.UpsertEntitlement(ctx, queries.UpsertEntitlementParams{ - UserID: sub.UserID, - PlanID: "pro", - ActiveUntil: sub.GraceUntil, - }); err != nil { - w.logger.Error("failed to extend entitlement through grace window", "user_id", sub.UserID, "error", err) - } - - // Send payment failed email - if w.email != nil { - user, userErr := w.queries.GetUserByID(ctx, job.UserID) - if userErr == nil { - if err := w.email.SendPaymentFailed(user.Email, "en", clients.PaymentFailedData{ - UserName: user.Name, - PlanName: "Pro", - Amount: fmt.Sprintf("%d", job.AmountMinor/100), - Currency: job.CurrencyCode, - RetryDate: time.Now().Add(time.Duration(1< i); + const results = []; + const workers = Array.from({ length: concurrency }, async () => { + while (queue.length > 0) { + const next = queue.shift(); + if (next === undefined) break; + results.push(await submitVoice(next)); + } + }); + + await Promise.all(workers); + + const summary = results.reduce( + (acc, item) => { + acc.total++; + acc.byStatus[item.status] = (acc.byStatus[item.status] || 0) + 1; + acc.maxLatencyMs = Math.max(acc.maxLatencyMs, item.latencyMs); + acc.totalLatencyMs += item.latencyMs; + if (item.status === 200) acc.completed200Count++; + return acc; + }, + { scenario, total: 0, byStatus: {}, maxLatencyMs: 0, totalLatencyMs: 0, completed200Count: 0 }, + ); + + summary.avgLatencyMs = summary.total === 0 ? 0 : Math.round(summary.totalLatencyMs / summary.total); + console.log(JSON.stringify(summary, null, 2)); +} + +run().catch((error) => { + console.error(error); + process.exitCode = 1; +});