From b1d349599ccc8d7d54f91f1d507ec8c1778cc54f Mon Sep 17 00:00:00 2001 From: Phloraxx Date: Sun, 2 Aug 2026 15:05:16 +0000 Subject: [PATCH 1/2] Add isolated Razorpay Test Mode rail --- .env.example | 7 + .env.razorpay-test.example | 13 + .gitignore | 2 + ARCHITECTURE.md | 23 ++ RAZORPAY_TEST.md | 86 +++++ README.md | 1 + cmd/payment-api/main.go | 9 + cmd/payment-api/main_test.go | 4 +- docker-compose.razorpay-test.yml | 22 ++ internal/api/api.go | 34 +- internal/api/razorpay.go | 152 +++++++++ internal/api/razorpay_api_test.go | 214 ++++++++++++ internal/config/config.go | 28 ++ internal/config/config_test.go | 44 +++ internal/razorpaytest/client.go | 161 +++++++++ internal/razorpaytest/client_test.go | 50 +++ internal/razorpaytest/service.go | 466 ++++++++++++++++++++++++++ internal/razorpaytest/service_test.go | 225 +++++++++++++ migrations/20260802000000_razorpay.go | 105 ++++++ migrations/migration_test.go | 2 +- web/src/App.tsx | 5 +- web/src/pages/RazorpayTest.tsx | 200 +++++++++++ web/src/styles.css | 3 + web/src/types.ts | 42 ++- 24 files changed, 1876 insertions(+), 22 deletions(-) create mode 100644 .env.razorpay-test.example create mode 100644 RAZORPAY_TEST.md create mode 100644 docker-compose.razorpay-test.yml create mode 100644 internal/api/razorpay.go create mode 100644 internal/api/razorpay_api_test.go create mode 100644 internal/razorpaytest/client.go create mode 100644 internal/razorpaytest/client_test.go create mode 100644 internal/razorpaytest/service.go create mode 100644 internal/razorpaytest/service_test.go create mode 100644 migrations/20260802000000_razorpay.go create mode 100644 web/src/pages/RazorpayTest.tsx diff --git a/.env.example b/.env.example index 436c479..ef29159 100644 --- a/.env.example +++ b/.env.example @@ -64,3 +64,10 @@ PAYGATE_BACKUP_S3_FORCE_PATH_STYLE=false # reconciliation and backup failures. Both values are required together. OPERATOR_ALERT_WEBHOOK_URL= OPERATOR_ALERT_WEBHOOK_SECRET= + +# Optional isolated Razorpay Test Mode rail. Keep disabled in production. +RAZORPAY_TEST_ENABLED=false +RAZORPAY_TEST_KEY_ID= +RAZORPAY_TEST_KEY_SECRET= +RAZORPAY_TEST_WEBHOOK_SECRET= +RAZORPAY_TEST_DISPLAY_NAME=PayGate Razorpay Test diff --git a/.env.razorpay-test.example b/.env.razorpay-test.example new file mode 100644 index 0000000..e7feda6 --- /dev/null +++ b/.env.razorpay-test.example @@ -0,0 +1,13 @@ +# Copy to .env.razorpay-test and fill values from the Razorpay Dashboard +# while the Dashboard is switched to Test Mode. Never use rzp_live_ keys. +RAZORPAY_TEST_KEY_ID=rzp_test_replace_me +RAZORPAY_TEST_KEY_SECRET=replace_with_test_key_secret +RAZORPAY_TEST_WEBHOOK_SECRET=replace_with_a_separate_random_webhook_secret +RAZORPAY_TEST_DISPLAY_NAME=PayGate Razorpay Test + +# Optional operator/API settings for this isolated staging instance. +PAYGATE_API_KEY=replace_with_a_random_staging_api_key +SMS_WEBHOOK_SECRET=replace_with_a_random_unused_staging_secret +UPI_PAYEE_NAME=PayGate Razorpay Test +STATEMENT_TIMEZONE=Asia/Kolkata +PAYGATE_RATE_LIMITS_ENABLED=true diff --git a/.gitignore b/.gitignore index fb039d1..cb9d44e 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,5 @@ tmp/ .vercel/ .dev.vars .wrangler/ + +.env.razorpay-test diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 7798898..fbed278 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -380,3 +380,26 @@ Bank evidence time is authoritative for on-time/late classification. Ingestion t Statement imports are intentionally report-first. Exact RRN+amount rows reconcile; contradictions create review cases. XLSX files are ZIP-validated before parsing to limit path traversal and decompression expansion. Backup configuration uses PocketBase's backup filesystem and cron. Archive verification reads every ZIP member. Restore drills extract to a temporary directory and run SQLite integrity checks without replacing production data. + +## 21. Isolated Razorpay Test rail + +The optional Razorpay module is deliberately not a generic payment-provider +abstraction. It is enabled only by `RAZORPAY_TEST_ENABLED=true` with an +`rzp_test_...` key and stores data in `razorpay_test_orders` and +`razorpay_test_events` rather than the SMS/DDM `payments` collection. + +The server creates every Razorpay order and returns only the public Test Key ID +to the authenticated operator UI. Checkout callbacks are HMAC-verified using +the server-stored order ID. A valid callback proves authenticity but does not +mean paid; only a provider state of `captured` does. + +Webhook processing verifies the raw request body, deduplicates +`X-Razorpay-Event-Id`, rejects reuse of an event ID with another payload hash, +and applies monotonic transitions so stale failures cannot downgrade captured +or refunded states. Full webhook payloads and customer payment details are not +retained. + +The provider client is restricted to the official Razorpay API base URL in +production, refuses redirects, uses bounded responses and timeouts, and has no +Live Mode path. The separate Compose profile and volume are the intended test +deployment boundary. diff --git a/RAZORPAY_TEST.md b/RAZORPAY_TEST.md new file mode 100644 index 0000000..1f9525f --- /dev/null +++ b/RAZORPAY_TEST.md @@ -0,0 +1,86 @@ +# Razorpay Test Rail + +This module is an isolated experiment. It does not modify PayGate's existing +SMS/DDM `payments` records and cannot be enabled with a Razorpay Live Mode key. + +## What it implements + +- server-side Razorpay Orders API calls; +- operator-only Standard Checkout launch; +- mandatory server-side checkout-signature verification; +- signed `payment.captured` and `payment.failed` webhooks; +- duplicate webhook-event protection using `X-Razorpay-Event-Id`; +- monotonic local state so a late failure cannot downgrade a captured payment; +- provider status refresh using the Fetch Payment API; +- separate `razorpay_test_orders` and `razorpay_test_events` collections; +- no storage of complete webhook payloads or customer payment details. + +Only a `captured` order is treated as successfully paid. A verified browser +callback by itself remains `verification_pending` or `authorized` until the +provider status confirms capture. + +## Start an isolated instance + +```bash +cp .env.razorpay-test.example .env.razorpay-test +# Edit .env.razorpay-test locally; never commit it. +docker compose -f docker-compose.razorpay-test.yml up --build +``` + +The service binds to `127.0.0.1:3001` and uses the separate +`paygate_razorpay_test_data` volume. Do not point it at the production volume. + +Create an operator account through PocketBase administration, sign in to the +PayGate operator UI, and open `#/razorpay_test`. + +## Razorpay Dashboard setup + +1. Switch the Razorpay Dashboard to **Test Mode**. +2. Generate Test Mode API keys. +3. Put the `rzp_test_...` Key ID and Key Secret into the staging environment. +4. Generate a separate random webhook secret. +5. Configure an HTTPS staging webhook URL: + +```text +https:///api/razorpay/test/webhook +``` + +6. Subscribe only to: + +```text +payment.captured +payment.failed +``` + +The connected ChatGPT Razorpay plugin is read-only and is not a substitute for +these API credentials or webhook configuration. + +## Test flow + +1. Create a ₹1.00 order from the operator page. +2. Checkout opens with the server-created Razorpay order ID. +3. Complete or fail the mock Test Mode payment. +4. The browser callback is signature-verified by PayGate. +5. PayGate fetches the payment state immediately. +6. The signed webhook independently confirms the final state. + +Suggested scenarios: + +- successful test UPI/payment; +- failed test payment; +- modified callback order, payment or signature; +- duplicate webhook event ID; +- `payment.failed` delivered after `payment.captured`; +- backend restart between checkout and webhook; +- webhook temporarily unavailable and later retried; +- same `Idempotency-Key` submitted twice; +- Live Mode key supplied to the test configuration (startup must fail). + +## Deliberate limitations + +- no Live Mode support; +- no refunds or captures initiated by PayGate; +- no generic payment-provider interface; +- no customer-facing production checkout route; +- no automatic migration of Razorpay test orders into normal PayGate payments; +- no raw webhook-payload retention. diff --git a/README.md b/README.md index c5ea5a9..751b155 100644 --- a/README.md +++ b/README.md @@ -367,5 +367,6 @@ A future proprietary/commercial distribution needs a separate licensing review r - `ARCHITECTURE.md` — implemented system design and invariants - `PLAN.md` — implementation/acceptance status - `OPERATIONS.md` — evidence review, reconciliation, refunds, alerts, backups and incident runbook +- `RAZORPAY_TEST.md` — isolated Razorpay Test Mode setup and verification flow - `RESEARCH.md` — technical research and constraints behind the design - `IMPLEMENTATION_SPEC.md` — rebuild requirements used during implementation diff --git a/cmd/payment-api/main.go b/cmd/payment-api/main.go index 1a0851f..63c63a6 100644 --- a/cmd/payment-api/main.go +++ b/cmd/payment-api/main.go @@ -19,6 +19,7 @@ import ( "github.com/Phloraxx/payment-api/internal/config" "github.com/Phloraxx/payment-api/internal/gmessages" "github.com/Phloraxx/payment-api/internal/payments" + "github.com/Phloraxx/payment-api/internal/razorpaytest" "github.com/Phloraxx/payment-api/internal/reconciliation" "github.com/Phloraxx/payment-api/internal/refunds" "github.com/Phloraxx/payment-api/internal/retention" @@ -64,6 +65,11 @@ func main() { } reconciliationService.StatementLocation = statementLocation refundService := refunds.NewService(app, auditService, webhookService) + var razorpayTestService *razorpaytest.Service + if cfg.RazorpayTestEnabled { + razorpayClient := razorpaytest.NewClient(cfg.RazorpayTestKeyID, cfg.RazorpayTestKeySecret) + razorpayTestService = razorpaytest.NewService(app, razorpayClient, cfg.RazorpayTestKeyID, cfg.RazorpayTestKeySecret, cfg.RazorpayTestWebhookSecret, cfg.RazorpayTestDisplayName) + } retentionService := retention.NewService(app, cfg) backupService := backups.NewService(app, cfg, alertService) backupService.RegisterHooks() @@ -77,6 +83,7 @@ func main() { apiService.Alerts = alertService apiService.Refunds = refundService apiService.Backups = backupService + apiService.RazorpayTest = razorpayTestService apiService.Register(app) registerPairCommand(app, cfg, gmessagesLogger) registerHealthcheckCommand(app) @@ -231,6 +238,8 @@ func mergeManagedRateLimitRules(existing []core.RateLimitRule) []core.RateLimitR {Label: "POST /api/events/sms", MaxRequests: 60, Duration: 60}, {Label: "POST /api/webhook", MaxRequests: 30, Duration: 60}, {Label: "POST /api/payments", MaxRequests: 120, Duration: 60}, + {Label: "POST /api/razorpay/test/orders", MaxRequests: 30, Duration: 60}, + {Label: "POST /api/razorpay/test/webhook", MaxRequests: 120, Duration: 60}, } labels := make(map[string]struct{}, len(managed)) for _, rule := range managed { diff --git a/cmd/payment-api/main_test.go b/cmd/payment-api/main_test.go index 7212daf..6def338 100644 --- a/cmd/payment-api/main_test.go +++ b/cmd/payment-api/main_test.go @@ -16,14 +16,14 @@ func TestMergeManagedRateLimitRulesIsIdempotentAndPreservesCustomRules(t *testin } first := mergeManagedRateLimitRules(initial) second := mergeManagedRateLimitRules(first) - if len(first) != 4 || len(second) != 4 { + if len(first) != 6 || len(second) != 6 { t.Fatalf("lengths first=%d second=%d", len(first), len(second)) } counts := map[string]int{} for _, rule := range second { counts[rule.Label]++ } - for _, label := range []string{"POST /api/events/sms", "POST /api/webhook", "POST /api/payments", "custom"} { + for _, label := range []string{"POST /api/events/sms", "POST /api/webhook", "POST /api/payments", "POST /api/razorpay/test/orders", "POST /api/razorpay/test/webhook", "custom"} { if counts[label] != 1 { t.Fatalf("label %s count=%d", label, counts[label]) } diff --git a/docker-compose.razorpay-test.yml b/docker-compose.razorpay-test.yml new file mode 100644 index 0000000..4ede143 --- /dev/null +++ b/docker-compose.razorpay-test.yml @@ -0,0 +1,22 @@ +services: + paygate-razorpay-test: + build: + context: . + ports: + - "127.0.0.1:3001:3000" + env_file: + - .env.razorpay-test + environment: + PB_DATA_DIR: /app/pb_data + PAYGATE_TEST_MODE: "true" + GMESSAGES_ENABLED: "false" + LEGACY_SMS_WEBHOOK_ENABLED: "false" + PAYGATE_BACKUP_CRON: "" + PAYGATE_RETENTION_ENABLED: "false" + RAZORPAY_TEST_ENABLED: "true" + volumes: + - paygate_razorpay_test_data:/app/pb_data + restart: unless-stopped + +volumes: + paygate_razorpay_test_data: diff --git a/internal/api/api.go b/internal/api/api.go index 7eeed11..a840506 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -18,6 +18,7 @@ import ( "github.com/Phloraxx/payment-api/internal/gmessages" "github.com/Phloraxx/payment-api/internal/money" "github.com/Phloraxx/payment-api/internal/payments" + "github.com/Phloraxx/payment-api/internal/razorpaytest" "github.com/Phloraxx/payment-api/internal/reconciliation" "github.com/Phloraxx/payment-api/internal/refunds" "github.com/Phloraxx/payment-api/internal/reviews" @@ -28,12 +29,13 @@ import ( ) const ( - maxPaymentRequestBytes int64 = (1 << 20) + (64 << 10) - maxSMSRequestBytes int64 = 128 << 10 - maxGMessagesPairBytes int64 = 128 << 10 - maxReviewRequestBytes int64 = 16 << 10 - maxRefundRequestBytes int64 = (1 << 20) + (64 << 10) - maxStatementRequestBytes int64 = reconciliation.MaxFileBytes + (1 << 20) + maxPaymentRequestBytes int64 = (1 << 20) + (64 << 10) + maxSMSRequestBytes int64 = 128 << 10 + maxGMessagesPairBytes int64 = 128 << 10 + maxReviewRequestBytes int64 = 16 << 10 + maxRefundRequestBytes int64 = (1 << 20) + (64 << 10) + maxStatementRequestBytes int64 = reconciliation.MaxFileBytes + (1 << 20) + maxRazorpayTestRequestBytes int64 = 1 << 20 ) type API struct { @@ -46,6 +48,7 @@ type API struct { Alerts *alerts.Service Refunds *refunds.Service Backups *backups.Service + RazorpayTest *razorpaytest.Service } func New(cfg config.Config, paymentService *payments.Service, smsService *sms.Service, manager *gmessages.Manager) *API { @@ -71,6 +74,12 @@ func (a *API) Register(app core.App) { e.Router.POST("/api/paygate/backups", a.createBackup) e.Router.POST("/api/paygate/backups/verify", a.verifyBackup) e.Router.POST("/api/paygate/backups/restore-drill", a.restoreDrill) + e.Router.GET("/api/razorpay/test/config", a.razorpayTestConfig) + e.Router.POST("/api/razorpay/test/orders", a.razorpayTestCreateOrder).Bind(apis.BodyLimit(maxRazorpayTestRequestBytes)) + e.Router.GET("/api/razorpay/test/orders/{id}", a.razorpayTestGetOrder) + e.Router.POST("/api/razorpay/test/orders/{id}/verify", a.razorpayTestVerify).Bind(apis.BodyLimit(maxRazorpayTestRequestBytes)) + e.Router.POST("/api/razorpay/test/orders/{id}/refresh", a.razorpayTestRefresh) + e.Router.POST("/api/razorpay/test/webhook", a.razorpayTestWebhook).Bind(apis.BodyLimit(maxRazorpayTestRequestBytes)) e.Router.GET("/api/connector/gmessages/status", a.gmessagesStatus) e.Router.POST("/api/connector/gmessages/pair/google", a.gmessagesGooglePair).Bind(apis.BodyLimit(maxGMessagesPairBytes)) e.Router.POST("/api/connector/gmessages/reauth/google", a.gmessagesGoogleReauth).Bind(apis.BodyLimit(maxGMessagesPairBytes)) @@ -90,7 +99,7 @@ func (a *API) Register(app core.App) { if path != "" && path != "index.html" && !strings.HasPrefix(path, "assets/") { return event.NotFoundError("route not found", nil) } - setOperatorSecurityHeaders(event) + a.setOperatorSecurityHeaders(event) return static(event) }) return e.Next() @@ -276,6 +285,7 @@ func (a *API) getConfig(e *core.RequestEvent) error { "backupOffsite": a.Config.BackupS3Enabled, "operatorAlertWebhookConfigured": a.Config.OperatorAlertWebhookURL != "", "statementTimezone": a.Config.StatementTimezone, + "razorpayTestEnabled": a.Config.RazorpayTestEnabled, "connector": a.connectorStatus(), }) } @@ -518,16 +528,6 @@ func (a *API) restoreDrill(e *core.RequestEvent) error { return e.JSON(http.StatusOK, result) } -func setOperatorSecurityHeaders(e *core.RequestEvent) { - headers := e.Response.Header() - headers.Set("Content-Security-Policy", "default-src 'self'; base-uri 'none'; connect-src 'self'; font-src 'self'; form-action 'self'; frame-ancestors 'none'; img-src 'self' data: blob:; object-src 'none'; script-src 'self'; style-src 'self'") - headers.Set("Permissions-Policy", "camera=(), geolocation=(), microphone=(), payment=()") - headers.Set("Referrer-Policy", "no-referrer") - headers.Set("Strict-Transport-Security", "max-age=31536000") - headers.Set("X-Content-Type-Options", "nosniff") - headers.Set("X-Frame-Options", "DENY") -} - func refundResponse(record *core.Record) map[string]any { if record == nil { return nil diff --git a/internal/api/razorpay.go b/internal/api/razorpay.go new file mode 100644 index 0000000..dc27316 --- /dev/null +++ b/internal/api/razorpay.go @@ -0,0 +1,152 @@ +package api + +import ( + "io" + "net/http" + "strings" + + "github.com/Phloraxx/payment-api/internal/razorpaytest" + "github.com/pocketbase/pocketbase/core" +) + +type razorpayTestCreateBody struct { + AmountPaise int64 `json:"amountPaise"` + ExternalID string `json:"externalId"` +} + +type razorpayTestVerifyBody struct { + RazorpayOrderID string `json:"razorpay_order_id"` + RazorpayPaymentID string `json:"razorpay_payment_id"` + RazorpaySignature string `json:"razorpay_signature"` +} + +func (a *API) razorpayTestConfig(e *core.RequestEvent) error { + if !a.dashboardAuth(e) { + return e.UnauthorizedError("dashboard authentication is required", nil) + } + enabled := a.Config.RazorpayTestEnabled && a.RazorpayTest != nil + keyID := "" + if enabled { + keyID = a.Config.RazorpayTestKeyID + } + return e.JSON(http.StatusOK, map[string]any{ + "enabled": enabled, + "keyId": keyID, + "displayName": a.Config.RazorpayTestDisplayName, + "mode": "test", + }) +} + +func (a *API) razorpayTestCreateOrder(e *core.RequestEvent) error { + if !a.dashboardAuth(e) { + return e.UnauthorizedError("dashboard authentication is required", nil) + } + if !a.razorpayTestAvailable() { + return e.NotFoundError("Razorpay test rail is disabled", nil) + } + var body razorpayTestCreateBody + if err := decodeJSON(e, &body); err != nil { + return e.BadRequestError("invalid JSON body", err) + } + record, replayed, err := a.RazorpayTest.Create(e.Request.Context(), razorpaytest.CreateInput{ + AmountPaise: body.AmountPaise, ExternalID: body.ExternalID, + IdempotencyKey: strings.TrimSpace(e.Request.Header.Get("Idempotency-Key")), ActorID: e.Auth.Id, + }) + if err != nil { + return writeDomainError(e, err) + } + status := http.StatusCreated + if replayed { + status = http.StatusOK + e.Response.Header().Set("X-Idempotent-Replayed", "true") + } + return e.JSON(status, razorpaytest.OrderResponse(record, a.Config.RazorpayTestKeyID, a.Config.RazorpayTestDisplayName)) +} + +func (a *API) razorpayTestGetOrder(e *core.RequestEvent) error { + if !a.dashboardAuth(e) { + return e.UnauthorizedError("dashboard authentication is required", nil) + } + if !a.razorpayTestAvailable() { + return e.NotFoundError("Razorpay test rail is disabled", nil) + } + record, err := a.RazorpayTest.Get(e.Request.PathValue("id")) + if err != nil { + return writeDomainError(e, err) + } + return e.JSON(http.StatusOK, razorpaytest.OrderResponse(record, a.Config.RazorpayTestKeyID, a.Config.RazorpayTestDisplayName)) +} + +func (a *API) razorpayTestVerify(e *core.RequestEvent) error { + if !a.dashboardAuth(e) { + return e.UnauthorizedError("dashboard authentication is required", nil) + } + if !a.razorpayTestAvailable() { + return e.NotFoundError("Razorpay test rail is disabled", nil) + } + var body razorpayTestVerifyBody + if err := decodeJSON(e, &body); err != nil { + return e.BadRequestError("invalid JSON body", err) + } + record, err := a.RazorpayTest.Verify(e.Request.Context(), razorpaytest.VerifyInput{ + LocalOrderID: e.Request.PathValue("id"), RazorpayOrderID: body.RazorpayOrderID, + RazorpayPaymentID: body.RazorpayPaymentID, RazorpaySignature: body.RazorpaySignature, + }) + if err != nil { + return writeDomainError(e, err) + } + return e.JSON(http.StatusOK, razorpaytest.OrderResponse(record, a.Config.RazorpayTestKeyID, a.Config.RazorpayTestDisplayName)) +} + +func (a *API) razorpayTestRefresh(e *core.RequestEvent) error { + if !a.dashboardAuth(e) { + return e.UnauthorizedError("dashboard authentication is required", nil) + } + if !a.razorpayTestAvailable() { + return e.NotFoundError("Razorpay test rail is disabled", nil) + } + record, err := a.RazorpayTest.Refresh(e.Request.Context(), e.Request.PathValue("id")) + if err != nil { + return writeDomainError(e, err) + } + return e.JSON(http.StatusOK, razorpaytest.OrderResponse(record, a.Config.RazorpayTestKeyID, a.Config.RazorpayTestDisplayName)) +} + +func (a *API) razorpayTestWebhook(e *core.RequestEvent) error { + if !a.razorpayTestAvailable() { + return e.NotFoundError("Razorpay test rail is disabled", nil) + } + raw, err := io.ReadAll(io.LimitReader(e.Request.Body, maxRazorpayTestRequestBytes+1)) + if err != nil { + return e.BadRequestError("failed to read Razorpay webhook", err) + } + if len(raw) > int(maxRazorpayTestRequestBytes) { + return e.JSON(http.StatusRequestEntityTooLarge, map[string]any{"error": map[string]any{"code": "RAZORPAY_TEST_WEBHOOK_TOO_LARGE", "message": "webhook exceeds 1 MiB"}}) + } + result, err := a.RazorpayTest.IngestWebhook( + e.Request.Header.Get("X-Razorpay-Event-Id"), + e.Request.Header.Get("X-Razorpay-Signature"), raw, + ) + if err != nil { + return writeDomainError(e, err) + } + return e.JSON(http.StatusOK, result) +} + +func (a *API) razorpayTestAvailable() bool { + return a.Config.RazorpayTestEnabled && a.RazorpayTest != nil +} + +func (a *API) setOperatorSecurityHeaders(e *core.RequestEvent) { + headers := e.Response.Header() + csp := "default-src 'self'; base-uri 'none'; connect-src 'self'; font-src 'self'; form-action 'self'; frame-ancestors 'none'; img-src 'self' data: blob:; object-src 'none'; script-src 'self'; style-src 'self'" + if a.Config.RazorpayTestEnabled { + csp = "default-src 'self'; base-uri 'none'; connect-src 'self' https://api.razorpay.com https://*.razorpay.com; font-src 'self' https://*.razorpay.com; form-action 'self' https://api.razorpay.com; frame-ancestors 'none'; frame-src https://api.razorpay.com https://*.razorpay.com; img-src 'self' data: blob: https://*.razorpay.com; object-src 'none'; script-src 'self' https://checkout.razorpay.com; style-src 'self'" + } + headers.Set("Content-Security-Policy", csp) + headers.Set("Permissions-Policy", "camera=(), geolocation=(), microphone=(), payment=()") + headers.Set("Referrer-Policy", "no-referrer") + headers.Set("Strict-Transport-Security", "max-age=31536000") + headers.Set("X-Content-Type-Options", "nosniff") + headers.Set("X-Frame-Options", "DENY") +} diff --git a/internal/api/razorpay_api_test.go b/internal/api/razorpay_api_test.go new file mode 100644 index 0000000..167f571 --- /dev/null +++ b/internal/api/razorpay_api_test.go @@ -0,0 +1,214 @@ +package api + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Phloraxx/payment-api/internal/config" + "github.com/Phloraxx/payment-api/internal/payments" + "github.com/Phloraxx/payment-api/internal/razorpaytest" + "github.com/Phloraxx/payment-api/internal/sms" + _ "github.com/Phloraxx/payment-api/migrations" + "github.com/pocketbase/pocketbase/apis" + "github.com/pocketbase/pocketbase/core" + "github.com/pocketbase/pocketbase/tests" +) + +type apiRazorpayProvider struct { + order razorpaytest.ProviderOrder + payment razorpaytest.ProviderPayment +} + +func (p *apiRazorpayProvider) CreateOrder(_ context.Context, amount int64, receipt string) (razorpaytest.ProviderOrder, error) { + order := p.order + if order.ID == "" { + order = razorpaytest.ProviderOrder{ID: "order_api_test", Amount: amount, Currency: "INR", Receipt: receipt, Status: "created"} + } + return order, nil +} + +func (p *apiRazorpayProvider) FetchPayment(_ context.Context, paymentID string) (razorpaytest.ProviderPayment, error) { + payment := p.payment + payment.ID = paymentID + return payment, nil +} + +type razorpayAPIFixture struct { + app *tests.TestApp + server *httptest.Server + token string + service *razorpaytest.Service + provider *apiRazorpayProvider +} + +func newRazorpayAPIFixture(t *testing.T, enabled bool) *razorpayAPIFixture { + t.Helper() + app, err := tests.NewTestApp() + if err != nil { + t.Fatal(err) + } + t.Cleanup(app.Cleanup) + users, _ := app.FindCollectionByNameOrId("users") + operator := core.NewRecord(users) + operator.SetEmail("razorpay@example.com") + operator.SetPassword("test-password-123") + if err := app.Save(operator); err != nil { + t.Fatal(err) + } + token, _ := operator.NewAuthToken() + cfg := config.Config{ + TestMode: true, PaymentTTL: 5, AmountQuarantine: 0, + RazorpayTestEnabled: enabled, RazorpayTestKeyID: "rzp_test_api", + RazorpayTestKeySecret: "checkout-secret-123456", RazorpayTestWebhookSecret: "webhook-secret-123456789012", + RazorpayTestDisplayName: "PayGate Test", + } + paymentService := payments.NewService(app, cfg, nil) + smsService := sms.NewService(app, paymentService) + provider := &apiRazorpayProvider{} + service := razorpaytest.NewService(app, provider, cfg.RazorpayTestKeyID, cfg.RazorpayTestKeySecret, cfg.RazorpayTestWebhookSecret, cfg.RazorpayTestDisplayName) + apiService := New(cfg, paymentService, smsService, nil) + if enabled { + apiService.RazorpayTest = service + } + apiService.Register(app) + router, err := apis.NewRouter(app) + if err != nil { + t.Fatal(err) + } + serveEvent := &core.ServeEvent{App: app, Router: router} + if err := app.OnServe().Trigger(serveEvent, func(e *core.ServeEvent) error { return nil }); err != nil { + t.Fatal(err) + } + mux, _ := serveEvent.Router.BuildMux() + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + return &razorpayAPIFixture{app: app, server: server, token: token, service: service, provider: provider} +} + +func (f *razorpayAPIFixture) request(t *testing.T, method, path, body string, authenticated bool, headers map[string]string) (*http.Response, string) { + t.Helper() + req, err := http.NewRequest(method, f.server.URL+path, strings.NewReader(body)) + if err != nil { + t.Fatal(err) + } + if body != "" { + req.Header.Set("Content-Type", "application/json") + } + if authenticated { + req.Header.Set("Authorization", f.token) + } + for key, value := range headers { + req.Header.Set(key, value) + } + res, err := f.server.Client().Do(req) + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + raw, _ := io.ReadAll(res.Body) + return res, string(raw) +} + +func TestRazorpayTestRoutesAreDisabledByDefault(t *testing.T) { + fixture := newRazorpayAPIFixture(t, false) + res, body := fixture.request(t, http.MethodGet, "/api/razorpay/test/config", "", true, nil) + if res.StatusCode != http.StatusOK || !strings.Contains(body, `"enabled":false`) { + t.Fatalf("config status=%d body=%s", res.StatusCode, body) + } + res, _ = fixture.request(t, http.MethodPost, "/api/razorpay/test/orders", `{"amountPaise":100}`, true, map[string]string{"Idempotency-Key": "disabled"}) + if res.StatusCode != http.StatusNotFound { + t.Fatalf("disabled create status=%d", res.StatusCode) + } +} + +func TestRazorpayTestHTTPCreateVerifyAndAuth(t *testing.T) { + fixture := newRazorpayAPIFixture(t, true) + res, _ := fixture.request(t, http.MethodPost, "/api/razorpay/test/orders", `{"amountPaise":100}`, false, map[string]string{"Idempotency-Key": "api-test"}) + if res.StatusCode != http.StatusUnauthorized { + t.Fatalf("unauth status=%d", res.StatusCode) + } + res, body := fixture.request(t, http.MethodPost, "/api/razorpay/test/orders", `{"amountPaise":100,"externalId":"test-order"}`, true, map[string]string{"Idempotency-Key": "api-test"}) + if res.StatusCode != http.StatusCreated || !strings.Contains(body, `"razorpayOrderId":"order_api_test"`) { + t.Fatalf("create status=%d body=%s", res.StatusCode, body) + } + localID := jsonStringField(t, body, "id") + fixture.provider.payment = razorpaytest.ProviderPayment{OrderID: "order_api_test", Amount: 100, Currency: "INR", Status: "captured", Method: "upi", Captured: true} + verifyBody := `{"razorpay_order_id":"order_api_test","razorpay_payment_id":"pay_api_test","razorpay_signature":"bad"}` + res, _ = fixture.request(t, http.MethodPost, "/api/razorpay/test/orders/"+localID+"/verify", verifyBody, true, nil) + if res.StatusCode != http.StatusBadRequest { + t.Fatalf("tampered verify status=%d", res.StatusCode) + } + signature := apiCheckoutSignature("checkout-secret-123456", "order_api_test", "pay_api_test") + verifyBody = `{"razorpay_order_id":"order_api_test","razorpay_payment_id":"pay_api_test","razorpay_signature":"` + signature + `"}` + res, body = fixture.request(t, http.MethodPost, "/api/razorpay/test/orders/"+localID+"/verify", verifyBody, true, nil) + if res.StatusCode != http.StatusOK || !strings.Contains(body, `"status":"captured"`) { + t.Fatalf("verify status=%d body=%s", res.StatusCode, body) + } +} + +func TestRazorpayWebhookRequiresSignatureAndDeduplicates(t *testing.T) { + fixture := newRazorpayAPIFixture(t, true) + res, createBody := fixture.request(t, http.MethodPost, "/api/razorpay/test/orders", `{"amountPaise":300}`, true, map[string]string{"Idempotency-Key": "webhook-api"}) + if res.StatusCode != http.StatusCreated { + t.Fatalf("create body=%s", createBody) + } + body := `{"event":"payment.captured","created_at":1785672000,"payload":{"payment":{"entity":{"id":"pay_hook_api","order_id":"order_api_test","amount":300,"currency":"INR","status":"captured","method":"upi","captured":true}}}}` + res, _ = fixture.request(t, http.MethodPost, "/api/razorpay/test/webhook", body, false, map[string]string{"X-Razorpay-Event-Id": "evt_api", "X-Razorpay-Signature": "bad"}) + if res.StatusCode != http.StatusUnauthorized { + t.Fatalf("invalid signature status=%d", res.StatusCode) + } + signature := razorpaytest.Sign("webhook-secret-123456789012", []byte(body)) + headers := map[string]string{"X-Razorpay-Event-Id": "evt_api", "X-Razorpay-Signature": signature} + res, response := fixture.request(t, http.MethodPost, "/api/razorpay/test/webhook", body, false, headers) + if res.StatusCode != http.StatusOK || !strings.Contains(response, `"processed":true`) { + t.Fatalf("webhook status=%d body=%s", res.StatusCode, response) + } + res, response = fixture.request(t, http.MethodPost, "/api/razorpay/test/webhook", body, false, headers) + if res.StatusCode != http.StatusOK || !strings.Contains(response, `"duplicate":true`) { + t.Fatalf("duplicate status=%d body=%s", res.StatusCode, response) + } +} + +func apiCheckoutSignature(secret, orderID, paymentID string) string { + mac := hmac.New(sha256.New, []byte(secret)) + _, _ = mac.Write([]byte(orderID + "|" + paymentID)) + return hex.EncodeToString(mac.Sum(nil)) +} + +func jsonStringField(t *testing.T, raw, key string) string { + t.Helper() + var value map[string]any + if err := json.Unmarshal([]byte(raw), &value); err != nil { + t.Fatal(err) + } + result, _ := value[key].(string) + if result == "" { + t.Fatalf("missing %s in %s", key, raw) + } + return result +} + +func TestRazorpayEnabledCSPAllowsOnlyRazorpayCheckoutOrigins(t *testing.T) { + fixture := newRazorpayAPIFixture(t, true) + res, _ := fixture.request(t, http.MethodGet, "/", "", false, nil) + if res.StatusCode != http.StatusOK { + t.Fatalf("root status=%d", res.StatusCode) + } + csp := res.Header.Get("Content-Security-Policy") + for _, required := range []string{"https://checkout.razorpay.com", "frame-src https://api.razorpay.com", "https://*.razorpay.com"} { + if !strings.Contains(csp, required) { + t.Fatalf("CSP missing %q: %s", required, csp) + } + } + if strings.Contains(csp, "'unsafe-inline'") || strings.Contains(csp, "'unsafe-eval'") { + t.Fatalf("CSP was weakened: %s", csp) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index cca89ff..66daffb 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -45,6 +45,11 @@ type Config struct { OperatorAlertWebhookURL string OperatorAlertWebhookSecret string StatementTimezone string + RazorpayTestEnabled bool + RazorpayTestKeyID string + RazorpayTestKeySecret string + RazorpayTestWebhookSecret string + RazorpayTestDisplayName string } func Load() (Config, error) { @@ -108,6 +113,10 @@ func Load() (Config, error) { if err != nil { return Config{}, err } + razorpayTestEnabled, err := boolEnv("RAZORPAY_TEST_ENABLED", false) + if err != nil { + return Config{}, err + } dataDir := strings.TrimSpace(env("PB_DATA_DIR", "./pb_data")) cfg := Config{ @@ -141,6 +150,11 @@ func Load() (Config, error) { OperatorAlertWebhookURL: strings.TrimSpace(os.Getenv("OPERATOR_ALERT_WEBHOOK_URL")), OperatorAlertWebhookSecret: strings.TrimSpace(os.Getenv("OPERATOR_ALERT_WEBHOOK_SECRET")), StatementTimezone: strings.TrimSpace(env("STATEMENT_TIMEZONE", "Asia/Kolkata")), + RazorpayTestEnabled: razorpayTestEnabled, + RazorpayTestKeyID: strings.TrimSpace(os.Getenv("RAZORPAY_TEST_KEY_ID")), + RazorpayTestKeySecret: strings.TrimSpace(os.Getenv("RAZORPAY_TEST_KEY_SECRET")), + RazorpayTestWebhookSecret: strings.TrimSpace(os.Getenv("RAZORPAY_TEST_WEBHOOK_SECRET")), + RazorpayTestDisplayName: strings.TrimSpace(env("RAZORPAY_TEST_DISPLAY_NAME", "PayGate Razorpay Test")), } cfg.GMessagesSessionPath = strings.TrimSpace(os.Getenv("GMESSAGES_SESSION_PATH")) if cfg.GMessagesSessionPath == "" { @@ -216,6 +230,20 @@ func (c Config) ValidateServe() error { return fmt.Errorf("OPERATOR_ALERT_WEBHOOK_SECRET must be at least %d characters", minPrimarySecretLength) } } + if c.RazorpayTestEnabled { + if !strings.HasPrefix(c.RazorpayTestKeyID, "rzp_test_") { + return errors.New("RAZORPAY_TEST_KEY_ID must be a Test Mode key beginning with rzp_test_") + } + if len(c.RazorpayTestKeySecret) < 16 { + return errors.New("RAZORPAY_TEST_KEY_SECRET must be at least 16 characters") + } + if len(c.RazorpayTestWebhookSecret) < minPrimarySecretLength { + return fmt.Errorf("RAZORPAY_TEST_WEBHOOK_SECRET must be at least %d characters", minPrimarySecretLength) + } + if c.RazorpayTestDisplayName == "" || len(c.RazorpayTestDisplayName) > 128 { + return errors.New("RAZORPAY_TEST_DISPLAY_NAME must be between 1 and 128 characters") + } + } if c.BackupS3Enabled { if c.BackupS3Bucket == "" || c.BackupS3Region == "" || c.BackupS3Endpoint == "" || c.BackupS3AccessKey == "" || c.BackupS3Secret == "" { return errors.New("all PAYGATE_BACKUP_S3_* values are required when S3 backup storage is enabled") diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 969dfc6..0ce532d 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -125,3 +125,47 @@ func TestTestModeStillValidatesStructuralConfiguration(t *testing.T) { t.Fatalf("test mode accepted invalid webhook URL: %v", err) } } + +func TestValidateServeRazorpayTestRequiresTestModeCredentials(t *testing.T) { + base := Config{ + TestMode: true, PaymentTTL: time.Minute, AmountQuarantine: time.Hour, + StatementTimezone: "Asia/Kolkata", BackupMaxKeep: 1, + RazorpayTestEnabled: true, RazorpayTestDisplayName: "PayGate Test", + } + cases := []struct { + name string + edit func(*Config) + }{ + {"live key rejected", func(c *Config) { + c.RazorpayTestKeyID = "rzp_live_example" + c.RazorpayTestKeySecret = "1234567890123456" + c.RazorpayTestWebhookSecret = "123456789012345678901234" + }}, + {"short key secret", func(c *Config) { + c.RazorpayTestKeyID = "rzp_test_example" + c.RazorpayTestKeySecret = "short" + c.RazorpayTestWebhookSecret = "123456789012345678901234" + }}, + {"short webhook secret", func(c *Config) { + c.RazorpayTestKeyID = "rzp_test_example" + c.RazorpayTestKeySecret = "1234567890123456" + c.RazorpayTestWebhookSecret = "short" + }}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cfg := base + tc.edit(&cfg) + if err := cfg.ValidateServe(); err == nil { + t.Fatal("expected validation error") + } + }) + } + valid := base + valid.RazorpayTestKeyID = "rzp_test_example" + valid.RazorpayTestKeySecret = "1234567890123456" + valid.RazorpayTestWebhookSecret = "123456789012345678901234" + if err := valid.ValidateServe(); err != nil { + t.Fatalf("valid Razorpay test config: %v", err) + } +} diff --git a/internal/razorpaytest/client.go b/internal/razorpaytest/client.go new file mode 100644 index 0000000..8911d46 --- /dev/null +++ b/internal/razorpaytest/client.go @@ -0,0 +1,161 @@ +package razorpaytest + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +const productionAPIBaseURL = "https://api.razorpay.com/v1" +const maxProviderResponseBytes = 1 << 20 + +type Client struct { + KeyID string + KeySecret string + HTTP *http.Client + baseURL string +} + +type ProviderOrder struct { + ID string `json:"id"` + Entity string `json:"entity"` + Amount int64 `json:"amount"` + Currency string `json:"currency"` + Receipt string `json:"receipt"` + Status string `json:"status"` +} + +type ProviderPayment struct { + ID string `json:"id"` + Entity string `json:"entity"` + Amount int64 `json:"amount"` + Currency string `json:"currency"` + Status string `json:"status"` + OrderID string `json:"order_id"` + Method string `json:"method"` + AmountRefunded int64 `json:"amount_refunded"` + Captured bool `json:"captured"` + ErrorCode string `json:"error_code"` + ErrorDescription string `json:"error_description"` +} + +func NewClient(keyID, keySecret string) *Client { + return &Client{ + KeyID: keyID, KeySecret: keySecret, + HTTP: &http.Client{ + Timeout: 12 * time.Second, + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + }, + }, + baseURL: productionAPIBaseURL, + } +} + +func (c *Client) CreateOrder(ctx context.Context, amountPaise int64, receipt string) (ProviderOrder, error) { + payload := map[string]any{ + "amount": amountPaise, "currency": "INR", "receipt": receipt, + "notes": map[string]string{"source": "paygate_razorpay_test"}, + } + var order ProviderOrder + if err := c.doJSON(ctx, http.MethodPost, "/orders", payload, &order); err != nil { + return ProviderOrder{}, err + } + if !strings.HasPrefix(order.ID, "order_") || order.Amount != amountPaise || !strings.EqualFold(order.Currency, "INR") { + return ProviderOrder{}, errors.New("razorpay returned an inconsistent order") + } + return order, nil +} + +func (c *Client) FetchPayment(ctx context.Context, paymentID string) (ProviderPayment, error) { + if !strings.HasPrefix(paymentID, "pay_") { + return ProviderPayment{}, errors.New("invalid razorpay payment id") + } + var payment ProviderPayment + if err := c.doJSON(ctx, http.MethodGet, "/payments/"+paymentID, nil, &payment); err != nil { + return ProviderPayment{}, err + } + if payment.ID != paymentID { + return ProviderPayment{}, errors.New("razorpay returned an inconsistent payment id") + } + return payment, nil +} + +func (c *Client) doJSON(ctx context.Context, method, path string, requestBody any, responseBody any) error { + var body io.Reader + if requestBody != nil { + raw, err := json.Marshal(requestBody) + if err != nil { + return err + } + body = bytes.NewReader(raw) + } + req, err := http.NewRequestWithContext(ctx, method, c.apiBaseURL()+path, body) + if err != nil { + return err + } + req.SetBasicAuth(c.KeyID, c.KeySecret) + req.Header.Set("Accept", "application/json") + if requestBody != nil { + req.Header.Set("Content-Type", "application/json") + } + res, err := c.httpClient().Do(req) + if err != nil { + return fmt.Errorf("razorpay request failed: %w", err) + } + defer res.Body.Close() + raw, err := io.ReadAll(io.LimitReader(res.Body, maxProviderResponseBytes+1)) + if err != nil { + return fmt.Errorf("read razorpay response: %w", err) + } + if len(raw) > maxProviderResponseBytes { + return errors.New("razorpay response exceeded 1 MiB") + } + if res.StatusCode < 200 || res.StatusCode >= 300 { + return fmt.Errorf("razorpay returned HTTP %d: %s", res.StatusCode, providerErrorMessage(raw)) + } + if err := json.Unmarshal(raw, responseBody); err != nil { + return fmt.Errorf("decode razorpay response: %w", err) + } + return nil +} + +func (c *Client) apiBaseURL() string { + if c.baseURL != "" { + return strings.TrimRight(c.baseURL, "/") + } + return productionAPIBaseURL +} + +func (c *Client) httpClient() *http.Client { + if c.HTTP != nil { + return c.HTTP + } + return NewClient(c.KeyID, c.KeySecret).HTTP +} + +func providerErrorMessage(raw []byte) string { + var envelope struct { + Error struct { + Code string `json:"code"` + Description string `json:"description"` + } `json:"error"` + } + if json.Unmarshal(raw, &envelope) == nil && envelope.Error.Description != "" { + return envelope.Error.Description + } + text := strings.TrimSpace(string(raw)) + if len(text) > 512 { + text = text[:512] + } + if text == "" { + return "empty error response" + } + return text +} diff --git a/internal/razorpaytest/client_test.go b/internal/razorpaytest/client_test.go new file mode 100644 index 0000000..6fd7ec4 --- /dev/null +++ b/internal/razorpaytest/client_test.go @@ -0,0 +1,50 @@ +package razorpaytest + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" +) + +func TestClientCreatesOrderWithBasicAuthAndRefusesRedirects(t *testing.T) { + var targetRequests int + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + targetRequests++ + w.WriteHeader(http.StatusOK) + })) + defer target.Close() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + user, password, ok := r.BasicAuth() + if !ok || user != "rzp_test_key" || password != "secret" { + t.Fatalf("basic auth user=%q password=%q ok=%v", user, password, ok) + } + if r.URL.Path != "/orders" { + t.Fatalf("path=%s", r.URL.Path) + } + http.Redirect(w, r, target.URL, http.StatusFound) + })) + defer server.Close() + client := NewClient("rzp_test_key", "secret") + client.baseURL = server.URL + _, err := client.CreateOrder(context.Background(), 100, "receipt") + if err == nil { + t.Fatal("expected redirect response to be rejected") + } + if targetRequests != 0 { + t.Fatalf("credentials could have been forwarded to redirect target: requests=%d", targetRequests) + } +} + +func TestClientValidatesProviderOrderAmount(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"order_test","amount":999,"currency":"INR","status":"created"}`)) + })) + defer server.Close() + client := NewClient("rzp_test_key", "secret") + client.baseURL = server.URL + if _, err := client.CreateOrder(context.Background(), 100, "receipt"); err == nil { + t.Fatal("expected inconsistent amount error") + } +} diff --git a/internal/razorpaytest/service.go b/internal/razorpaytest/service.go new file mode 100644 index 0000000..283597a --- /dev/null +++ b/internal/razorpaytest/service.go @@ -0,0 +1,466 @@ +package razorpaytest + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "crypto/subtle" + "database/sql" + "encoding/hex" + "encoding/json" + "errors" + "strings" + "time" + + "github.com/Phloraxx/payment-api/internal/domain" + "github.com/pocketbase/pocketbase/core" +) + +const maxWebhookBytes = 1 << 20 + +type ProviderClient interface { + CreateOrder(ctx context.Context, amountPaise int64, receipt string) (ProviderOrder, error) + FetchPayment(ctx context.Context, paymentID string) (ProviderPayment, error) +} + +type Service struct { + App core.App + Client ProviderClient + KeyID string + KeySecret string + WebhookSecret string + DisplayName string + Now func() time.Time +} + +type CreateInput struct { + AmountPaise int64 + ExternalID string + IdempotencyKey string + ActorID string +} + +type VerifyInput struct { + LocalOrderID string + RazorpayOrderID string + RazorpayPaymentID string + RazorpaySignature string +} + +type WebhookResult struct { + Duplicate bool `json:"duplicate"` + Processed bool `json:"processed"` + Ignored bool `json:"ignored"` + EventID string `json:"eventId"` + OrderID string `json:"orderId,omitempty"` + Status string `json:"status,omitempty"` +} + +type webhookEnvelope struct { + Event string `json:"event"` + CreatedAt int64 `json:"created_at"` + Payload struct { + Payment struct { + Entity ProviderPayment `json:"entity"` + } `json:"payment"` + } `json:"payload"` +} + +func NewService(app core.App, client ProviderClient, keyID, keySecret, webhookSecret, displayName string) *Service { + return &Service{ + App: app, Client: client, KeyID: keyID, KeySecret: keySecret, + WebhookSecret: webhookSecret, DisplayName: displayName, Now: time.Now, + } +} + +func (s *Service) Create(ctx context.Context, input CreateInput) (*core.Record, bool, error) { + input.ExternalID = strings.TrimSpace(input.ExternalID) + input.IdempotencyKey = strings.TrimSpace(input.IdempotencyKey) + if input.AmountPaise < 100 || input.AmountPaise > 100_000_00 { + return nil, false, domain.New("RAZORPAY_TEST_INVALID_AMOUNT", "test amount must be between ₹1 and ₹1,00,000", 400) + } + if input.IdempotencyKey == "" || len(input.IdempotencyKey) > 255 { + return nil, false, domain.New("RAZORPAY_TEST_IDEMPOTENCY_REQUIRED", "a valid Idempotency-Key is required", 400) + } + if len(input.ExternalID) > 255 { + return nil, false, domain.InvalidExternalID() + } + + if existing, err := s.App.FindFirstRecordByData("razorpay_test_orders", "idempotency_key", input.IdempotencyKey); err == nil { + if int64(existing.GetInt("amount")) != input.AmountPaise || existing.GetString("external_id") != input.ExternalID { + return nil, false, domain.New("RAZORPAY_TEST_IDEMPOTENCY_CONFLICT", "the idempotency key was already used with different parameters", 409) + } + if status := existing.GetString("status"); status == "creating" || status == "create_failed" { + domainErr := domain.New("RAZORPAY_TEST_CREATE_STATE_UNKNOWN", "the previous provider-order attempt did not complete cleanly; inspect the Razorpay Test Dashboard using the local receipt before starting a new attempt", 409) + domainErr.Details = map[string]any{"localOrderId": existing.Id, "receipt": "pgt_" + existing.Id, "status": status} + return nil, false, domainErr + } + return existing, true, nil + } else if !errors.Is(err, sql.ErrNoRows) { + return nil, false, err + } + + collection, err := s.App.FindCollectionByNameOrId("razorpay_test_orders") + if err != nil { + return nil, false, err + } + now := s.now() + record := core.NewRecord(collection) + record.Set("amount", input.AmountPaise) + record.Set("currency", "INR") + record.Set("status", "creating") + record.Set("external_id", input.ExternalID) + record.Set("idempotency_key", input.IdempotencyKey) + record.Set("created_by", input.ActorID) + record.Set("created_at", now) + if err := s.App.Save(record); err != nil { + if existing, findErr := s.App.FindFirstRecordByData("razorpay_test_orders", "idempotency_key", input.IdempotencyKey); findErr == nil { + return existing, true, nil + } + return nil, false, err + } + + providerOrder, err := s.Client.CreateOrder(ctx, input.AmountPaise, "pgt_"+record.Id) + if err != nil { + record.Set("status", "create_failed") + record.Set("error", truncate(err.Error(), 4096)) + record.Set("last_synced_at", now) + _ = s.App.Save(record) + domainErr := domain.New("RAZORPAY_TEST_CREATE_FAILED", "Razorpay test order creation failed", 502) + domainErr.Details = map[string]any{"localOrderId": record.Id} + return record, false, domainErr + } + record.Set("razorpay_order_id", providerOrder.ID) + record.Set("provider_status", providerOrder.Status) + record.Set("status", "created") + record.Set("error", "") + record.Set("last_synced_at", now) + if err := s.App.Save(record); err != nil { + return nil, false, err + } + return record, false, nil +} + +func (s *Service) Get(localOrderID string) (*core.Record, error) { + record, err := s.App.FindRecordById("razorpay_test_orders", strings.TrimSpace(localOrderID)) + if errors.Is(err, sql.ErrNoRows) { + return nil, domain.New("RAZORPAY_TEST_ORDER_NOT_FOUND", "Razorpay test order not found", 404) + } + return record, err +} + +func (s *Service) Verify(ctx context.Context, input VerifyInput) (*core.Record, error) { + record, err := s.Get(input.LocalOrderID) + if err != nil { + return nil, err + } + providerOrderID := record.GetString("razorpay_order_id") + if providerOrderID == "" || input.RazorpayOrderID != providerOrderID { + return nil, domain.New("RAZORPAY_TEST_ORDER_MISMATCH", "checkout order id does not match the server-created order", 400) + } + if !strings.HasPrefix(input.RazorpayPaymentID, "pay_") { + return nil, domain.New("RAZORPAY_TEST_INVALID_PAYMENT", "invalid Razorpay payment id", 400) + } + if !verifyHexHMAC(s.KeySecret, providerOrderID+"|"+input.RazorpayPaymentID, input.RazorpaySignature) { + return nil, domain.New("RAZORPAY_TEST_SIGNATURE_INVALID", "Razorpay checkout signature verification failed", 400) + } + + err = s.App.RunInTransaction(func(tx core.App) error { + current, err := tx.FindRecordById("razorpay_test_orders", record.Id) + if err != nil { + return err + } + if existing := current.GetString("razorpay_payment_id"); existing != "" && existing != input.RazorpayPaymentID { + return domain.New("RAZORPAY_TEST_PAYMENT_CONFLICT", "the order is already linked to another Razorpay payment", 409) + } + if other, findErr := tx.FindFirstRecordByData("razorpay_test_orders", "razorpay_payment_id", input.RazorpayPaymentID); findErr == nil && other.Id != current.Id { + return domain.New("RAZORPAY_TEST_PAYMENT_CONFLICT", "the Razorpay payment is already linked to another test order", 409) + } else if findErr != nil && !errors.Is(findErr, sql.ErrNoRows) { + return findErr + } + current.Set("razorpay_payment_id", input.RazorpayPaymentID) + current.Set("signature_verified_at", s.now()) + if current.GetString("status") != "captured" && current.GetString("status") != "refunded" && current.GetString("status") != "partially_refunded" { + current.Set("status", "verification_pending") + } + return tx.Save(current) + }) + if err != nil { + return nil, err + } + + // A signed browser callback proves authenticity, not capture. Fetch the + // provider state immediately for responsive test UX; webhooks remain the + // authoritative asynchronous path if this fetch fails. + if _, refreshErr := s.Refresh(ctx, record.Id); refreshErr != nil { + var domainErr *domain.Error + if errors.As(refreshErr, &domainErr) && domainErr.Code != "RAZORPAY_TEST_REFRESH_FAILED" { + return nil, refreshErr + } + return s.Get(record.Id) + } + return s.Get(record.Id) +} + +func (s *Service) Refresh(ctx context.Context, localOrderID string) (*core.Record, error) { + record, err := s.Get(localOrderID) + if err != nil { + return nil, err + } + paymentID := record.GetString("razorpay_payment_id") + if paymentID == "" { + return nil, domain.New("RAZORPAY_TEST_PAYMENT_UNKNOWN", "no Razorpay payment id is linked to this order yet", 409) + } + payment, err := s.Client.FetchPayment(ctx, paymentID) + if err != nil { + return nil, domain.New("RAZORPAY_TEST_REFRESH_FAILED", "could not fetch the Razorpay payment", 502) + } + if err := s.applyPayment(record.Id, payment, s.now()); err != nil { + return nil, err + } + return s.Get(record.Id) +} + +func (s *Service) IngestWebhook(eventID, signature string, raw []byte) (WebhookResult, error) { + eventID = strings.TrimSpace(eventID) + if eventID == "" || len(eventID) > 128 { + return WebhookResult{}, domain.New("RAZORPAY_TEST_EVENT_ID_REQUIRED", "X-Razorpay-Event-Id is required", 400) + } + if len(raw) == 0 || len(raw) > maxWebhookBytes { + return WebhookResult{}, domain.New("RAZORPAY_TEST_WEBHOOK_INVALID", "webhook body must be between 1 byte and 1 MiB", 400) + } + if !verifyHexHMAC(s.WebhookSecret, string(raw), signature) { + return WebhookResult{}, domain.New("RAZORPAY_TEST_WEBHOOK_SIGNATURE_INVALID", "invalid Razorpay webhook signature", 401) + } + hashBytes := sha256.Sum256(raw) + payloadHash := hex.EncodeToString(hashBytes[:]) + if existing, err := s.App.FindFirstRecordByData("razorpay_test_events", "event_id", eventID); err == nil { + if existing.GetString("payload_hash") != payloadHash { + return WebhookResult{}, domain.New("RAZORPAY_TEST_EVENT_ID_CONFLICT", "the Razorpay event id was already used with a different payload", 409) + } + return WebhookResult{Duplicate: true, EventID: eventID, OrderID: existing.GetString("test_order"), Status: existing.GetString("status")}, nil + } else if !errors.Is(err, sql.ErrNoRows) { + return WebhookResult{}, err + } + + var envelope webhookEnvelope + if err := json.Unmarshal(raw, &envelope); err != nil { + return WebhookResult{}, domain.New("RAZORPAY_TEST_WEBHOOK_INVALID", "invalid Razorpay webhook JSON", 400) + } + payment := envelope.Payload.Payment.Entity + result := WebhookResult{EventID: eventID} + now := s.now() + err := s.App.RunInTransaction(func(tx core.App) error { + if existing, err := tx.FindFirstRecordByData("razorpay_test_events", "event_id", eventID); err == nil { + if existing.GetString("payload_hash") != payloadHash { + return domain.New("RAZORPAY_TEST_EVENT_ID_CONFLICT", "the Razorpay event id was already used with a different payload", 409) + } + result.Duplicate = true + result.OrderID = existing.GetString("test_order") + result.Status = existing.GetString("status") + return nil + } else if !errors.Is(err, sql.ErrNoRows) { + return err + } + collection, err := tx.FindCollectionByNameOrId("razorpay_test_events") + if err != nil { + return err + } + event := core.NewRecord(collection) + event.Set("event_id", eventID) + event.Set("event_type", truncate(envelope.Event, 128)) + event.Set("razorpay_order_id", payment.OrderID) + event.Set("razorpay_payment_id", payment.ID) + event.Set("payload_hash", payloadHash) + event.Set("received_at", now) + if envelope.CreatedAt > 0 { + event.Set("provider_created_at", time.Unix(envelope.CreatedAt, 0).UTC()) + } + + order, findErr := tx.FindFirstRecordByData("razorpay_test_orders", "razorpay_order_id", payment.OrderID) + if errors.Is(findErr, sql.ErrNoRows) { + event.Set("status", "ignored") + event.Set("error", "No local Razorpay test order matches this event") + result.Ignored = true + result.Status = "ignored" + return tx.Save(event) + } + if findErr != nil { + return findErr + } + event.Set("test_order", order.Id) + result.OrderID = order.Id + if envelope.Event != "payment.captured" && envelope.Event != "payment.failed" { + event.Set("status", "ignored") + result.Ignored = true + result.Status = "ignored" + return tx.Save(event) + } + if err := validateProviderPayment(order, payment); err != nil { + event.Set("status", "failed") + event.Set("error", truncate(err.Error(), 4096)) + result.Status = "failed" + return tx.Save(event) + } + if err := applyProviderPayment(order, payment, now); err != nil { + return err + } + if err := tx.Save(order); err != nil { + return err + } + event.Set("status", "processed") + result.Processed = true + result.Status = order.GetString("status") + return tx.Save(event) + }) + return result, err +} + +func (s *Service) applyPayment(localOrderID string, payment ProviderPayment, at time.Time) error { + return s.App.RunInTransaction(func(tx core.App) error { + order, err := tx.FindRecordById("razorpay_test_orders", localOrderID) + if err != nil { + return err + } + if err := validateProviderPayment(order, payment); err != nil { + return err + } + if err := applyProviderPayment(order, payment, at); err != nil { + return err + } + return tx.Save(order) + }) +} + +func validateProviderPayment(order *core.Record, payment ProviderPayment) error { + if payment.ID == "" || payment.OrderID != order.GetString("razorpay_order_id") { + return domain.New("RAZORPAY_TEST_PROVIDER_MISMATCH", "Razorpay payment does not belong to the local order", 409) + } + if payment.Amount != int64(order.GetInt("amount")) || !strings.EqualFold(payment.Currency, order.GetString("currency")) { + return domain.New("RAZORPAY_TEST_PROVIDER_MISMATCH", "Razorpay payment amount or currency does not match the local order", 409) + } + return nil +} + +func applyProviderPayment(order *core.Record, payment ProviderPayment, at time.Time) error { + if existing := order.GetString("razorpay_payment_id"); existing != "" && existing != payment.ID { + return domain.New("RAZORPAY_TEST_PAYMENT_CONFLICT", "the local order is linked to another Razorpay payment", 409) + } + current := order.GetString("status") + next := localStatus(payment) + if !shouldApplyStatus(current, next) { + return nil + } + order.Set("razorpay_payment_id", payment.ID) + order.Set("payment_method", truncate(payment.Method, 64)) + order.Set("provider_status", truncate(payment.Status, 64)) + order.Set("amount_refunded", payment.AmountRefunded) + order.Set("last_synced_at", at) + order.Set("error", truncate(firstNonEmpty(payment.ErrorDescription, payment.ErrorCode), 4096)) + order.Set("status", next) + switch next { + case "captured": + order.Set("captured_at", at) + case "failed": + order.Set("failed_at", at) + } + return nil +} + +func localStatus(payment ProviderPayment) string { + if payment.AmountRefunded >= payment.Amount && payment.Amount > 0 { + return "refunded" + } + if payment.AmountRefunded > 0 { + return "partially_refunded" + } + switch payment.Status { + case "captured": + return "captured" + case "authorized": + return "authorized" + case "failed": + return "failed" + case "refunded": + return "refunded" + default: + return "verification_pending" + } +} + +func shouldApplyStatus(current, next string) bool { + if current == next { + return true + } + if current == "refunded" { + return false + } + if current == "partially_refunded" { + return next == "refunded" + } + if current == "captured" { + return next == "partially_refunded" || next == "refunded" + } + if next == "captured" || next == "partially_refunded" || next == "refunded" { + return true + } + if current == "failed" { + return false + } + return true +} + +func verifyHexHMAC(secret, message, provided string) bool { + providedBytes, err := hex.DecodeString(strings.TrimSpace(provided)) + if err != nil { + return false + } + mac := hmac.New(sha256.New, []byte(secret)) + _, _ = mac.Write([]byte(message)) + expected := mac.Sum(nil) + return len(providedBytes) == len(expected) && subtle.ConstantTimeCompare(providedBytes, expected) == 1 +} + +func Sign(secret string, body []byte) string { + mac := hmac.New(sha256.New, []byte(secret)) + _, _ = mac.Write(body) + return hex.EncodeToString(mac.Sum(nil)) +} + +func OrderResponse(record *core.Record, keyID, displayName string) map[string]any { + if record == nil { + return nil + } + return map[string]any{ + "id": record.Id, "amountPaise": record.GetInt("amount"), "currency": record.GetString("currency"), + "status": record.GetString("status"), "externalId": record.GetString("external_id"), + "razorpayOrderId": record.GetString("razorpay_order_id"), "razorpayPaymentId": record.GetString("razorpay_payment_id"), + "providerStatus": record.GetString("provider_status"), "paymentMethod": record.GetString("payment_method"), + "amountRefunded": record.GetInt("amount_refunded"), "error": record.GetString("error"), + "createdAt": record.GetDateTime("created_at").String(), "capturedAt": record.GetDateTime("captured_at").String(), + "keyId": keyID, "displayName": displayName, + } +} + +func (s *Service) now() time.Time { + if s.Now == nil { + return time.Now().UTC() + } + return s.Now().UTC() +} + +func truncate(value string, max int) string { + if len(value) <= max { + return value + } + return value[:max] +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + return "" +} diff --git a/internal/razorpaytest/service_test.go b/internal/razorpaytest/service_test.go new file mode 100644 index 0000000..145bc4b --- /dev/null +++ b/internal/razorpaytest/service_test.go @@ -0,0 +1,225 @@ +package razorpaytest + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "testing" + "time" + + "github.com/Phloraxx/payment-api/internal/domain" + _ "github.com/Phloraxx/payment-api/migrations" + "github.com/pocketbase/pocketbase/tests" +) + +type fakeProviderClient struct { + createCalls int + order ProviderOrder + createErr error + payment ProviderPayment + fetchErr error +} + +func (f *fakeProviderClient) CreateOrder(_ context.Context, amount int64, receipt string) (ProviderOrder, error) { + f.createCalls++ + if f.createErr != nil { + return ProviderOrder{}, f.createErr + } + order := f.order + if order.ID == "" { + order = ProviderOrder{ID: "order_test_123", Amount: amount, Currency: "INR", Receipt: receipt, Status: "created"} + } + return order, nil +} + +func (f *fakeProviderClient) FetchPayment(_ context.Context, paymentID string) (ProviderPayment, error) { + if f.fetchErr != nil { + return ProviderPayment{}, f.fetchErr + } + payment := f.payment + if payment.ID == "" { + payment.ID = paymentID + } + return payment, nil +} + +func testService(t *testing.T) (*Service, *fakeProviderClient, *tests.TestApp, *time.Time) { + t.Helper() + app, err := tests.NewTestApp() + if err != nil { + t.Fatal(err) + } + t.Cleanup(app.Cleanup) + now := time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC) + client := &fakeProviderClient{} + service := NewService(app, client, "rzp_test_key", "checkout-secret-123456", "webhook-secret-123456789012", "PayGate Test") + service.Now = func() time.Time { return now } + return service, client, app, &now +} + +func TestCreateIsIdempotentAndDoesNotCreateProviderDuplicates(t *testing.T) { + service, client, _, _ := testService(t) + first, replayed, err := service.Create(context.Background(), CreateInput{AmountPaise: 100, ExternalID: "order-1", IdempotencyKey: "idem-1"}) + if err != nil || replayed { + t.Fatalf("first=%v replayed=%v err=%v", first, replayed, err) + } + second, replayed, err := service.Create(context.Background(), CreateInput{AmountPaise: 100, ExternalID: "order-1", IdempotencyKey: "idem-1"}) + if err != nil || !replayed || second.Id != first.Id { + t.Fatalf("second=%v replayed=%v err=%v", second, replayed, err) + } + if client.createCalls != 1 { + t.Fatalf("provider create calls=%d", client.createCalls) + } + _, _, err = service.Create(context.Background(), CreateInput{AmountPaise: 200, ExternalID: "order-1", IdempotencyKey: "idem-1"}) + var domainErr *domain.Error + if !errors.As(err, &domainErr) || domainErr.Code != "RAZORPAY_TEST_IDEMPOTENCY_CONFLICT" { + t.Fatalf("conflict error=%v", err) + } +} + +func TestVerifyRejectsTamperingAndOnlyCapturedProviderStateMarksPaid(t *testing.T) { + service, client, _, now := testService(t) + order, _, err := service.Create(context.Background(), CreateInput{AmountPaise: 250, IdempotencyKey: "idem-verify"}) + if err != nil { + t.Fatal(err) + } + paymentID := "pay_test_123" + client.payment = ProviderPayment{ + ID: paymentID, OrderID: order.GetString("razorpay_order_id"), Amount: 250, + Currency: "INR", Status: "captured", Captured: true, Method: "upi", + } + _, err = service.Verify(context.Background(), VerifyInput{ + LocalOrderID: order.Id, RazorpayOrderID: order.GetString("razorpay_order_id"), + RazorpayPaymentID: paymentID, RazorpaySignature: "tampered", + }) + var domainErr *domain.Error + if !errors.As(err, &domainErr) || domainErr.Code != "RAZORPAY_TEST_SIGNATURE_INVALID" { + t.Fatalf("tampered error=%v", err) + } + signature := checkoutSignature(service.KeySecret, order.GetString("razorpay_order_id"), paymentID) + verified, err := service.Verify(context.Background(), VerifyInput{ + LocalOrderID: order.Id, RazorpayOrderID: order.GetString("razorpay_order_id"), + RazorpayPaymentID: paymentID, RazorpaySignature: signature, + }) + if err != nil { + t.Fatal(err) + } + if verified.GetString("status") != "captured" || verified.GetString("payment_method") != "upi" || verified.GetDateTime("captured_at").Time() != *now { + t.Fatalf("verified status=%s method=%s captured=%s", verified.GetString("status"), verified.GetString("payment_method"), verified.GetDateTime("captured_at")) + } +} + +func TestWebhookIsSignedDeduplicatedAndMonotonic(t *testing.T) { + service, _, app, _ := testService(t) + order, _, err := service.Create(context.Background(), CreateInput{AmountPaise: 500, IdempotencyKey: "idem-webhook"}) + if err != nil { + t.Fatal(err) + } + captured := webhookBody(t, "payment.captured", ProviderPayment{ + ID: "pay_webhook_1", OrderID: order.GetString("razorpay_order_id"), Amount: 500, + Currency: "INR", Status: "captured", Captured: true, Method: "upi", + }) + result, err := service.IngestWebhook("evt_captured", Sign(service.WebhookSecret, captured), captured) + if err != nil || !result.Processed || result.Status != "captured" { + t.Fatalf("captured result=%+v err=%v", result, err) + } + duplicate, err := service.IngestWebhook("evt_captured", Sign(service.WebhookSecret, captured), captured) + if err != nil || !duplicate.Duplicate { + t.Fatalf("duplicate=%+v err=%v", duplicate, err) + } + failed := webhookBody(t, "payment.failed", ProviderPayment{ + ID: "pay_webhook_1", OrderID: order.GetString("razorpay_order_id"), Amount: 500, + Currency: "INR", Status: "failed", ErrorDescription: "late failed event", + }) + if _, err := service.IngestWebhook("evt_failed_late", Sign(service.WebhookSecret, failed), failed); err != nil { + t.Fatal(err) + } + stored, _ := app.FindRecordById("razorpay_test_orders", order.Id) + if stored.GetString("status") != "captured" || stored.GetString("error") != "" || stored.GetString("provider_status") != "captured" { + t.Fatalf("captured order was changed by stale failure: status=%s provider=%s error=%q", stored.GetString("status"), stored.GetString("provider_status"), stored.GetString("error")) + } + if count, _ := app.CountRecords("razorpay_test_events"); count != 2 { + t.Fatalf("event count=%d", count) + } +} + +func TestWebhookRejectsInvalidSignatureAndPersistsMismatchAsFailed(t *testing.T) { + service, _, app, _ := testService(t) + order, _, err := service.Create(context.Background(), CreateInput{AmountPaise: 700, IdempotencyKey: "idem-mismatch"}) + if err != nil { + t.Fatal(err) + } + body := webhookBody(t, "payment.captured", ProviderPayment{ + ID: "pay_mismatch", OrderID: order.GetString("razorpay_order_id"), Amount: 701, + Currency: "INR", Status: "captured", + }) + _, err = service.IngestWebhook("evt_invalid_sig", "invalid", body) + var domainErr *domain.Error + if !errors.As(err, &domainErr) || domainErr.Code != "RAZORPAY_TEST_WEBHOOK_SIGNATURE_INVALID" { + t.Fatalf("signature error=%v", err) + } + result, err := service.IngestWebhook("evt_mismatch", Sign(service.WebhookSecret, body), body) + if err != nil || result.Status != "failed" { + t.Fatalf("mismatch=%+v err=%v", result, err) + } + stored, _ := app.FindRecordById("razorpay_test_orders", order.Id) + if stored.GetString("status") != "created" { + t.Fatalf("mismatched event changed order to %s", stored.GetString("status")) + } +} + +func checkoutSignature(secret, orderID, paymentID string) string { + mac := hmac.New(sha256.New, []byte(secret)) + _, _ = mac.Write([]byte(orderID + "|" + paymentID)) + return hex.EncodeToString(mac.Sum(nil)) +} + +func webhookBody(t *testing.T, event string, payment ProviderPayment) []byte { + t.Helper() + body, err := json.Marshal(map[string]any{ + "event": event, "created_at": int64(1785672000), + "payload": map[string]any{"payment": map[string]any{"entity": payment}}, + }) + if err != nil { + t.Fatal(err) + } + return body +} + +func TestCreateFailureCannotBeSilentlyRetriedWithSameIdempotencyKey(t *testing.T) { + service, client, _, _ := testService(t) + client.createErr = errors.New("provider timeout") + _, _, err := service.Create(context.Background(), CreateInput{AmountPaise: 100, IdempotencyKey: "idem-timeout"}) + var domainErr *domain.Error + if !errors.As(err, &domainErr) || domainErr.Code != "RAZORPAY_TEST_CREATE_FAILED" { + t.Fatalf("first error=%v", err) + } + _, _, err = service.Create(context.Background(), CreateInput{AmountPaise: 100, IdempotencyKey: "idem-timeout"}) + if !errors.As(err, &domainErr) || domainErr.Code != "RAZORPAY_TEST_CREATE_STATE_UNKNOWN" { + t.Fatalf("replay error=%v", err) + } + if client.createCalls != 1 { + t.Fatalf("provider create calls=%d", client.createCalls) + } +} + +func TestWebhookRejectsEventIDReusedWithDifferentPayload(t *testing.T) { + service, _, _, _ := testService(t) + order, _, err := service.Create(context.Background(), CreateInput{AmountPaise: 900, IdempotencyKey: "idem-event-conflict"}) + if err != nil { + t.Fatal(err) + } + first := webhookBody(t, "payment.captured", ProviderPayment{ID: "pay_conflict", OrderID: order.GetString("razorpay_order_id"), Amount: 900, Currency: "INR", Status: "captured"}) + if _, err := service.IngestWebhook("evt_same", Sign(service.WebhookSecret, first), first); err != nil { + t.Fatal(err) + } + second := webhookBody(t, "payment.failed", ProviderPayment{ID: "pay_conflict", OrderID: order.GetString("razorpay_order_id"), Amount: 900, Currency: "INR", Status: "failed"}) + _, err = service.IngestWebhook("evt_same", Sign(service.WebhookSecret, second), second) + var domainErr *domain.Error + if !errors.As(err, &domainErr) || domainErr.Code != "RAZORPAY_TEST_EVENT_ID_CONFLICT" { + t.Fatalf("error=%v", err) + } +} diff --git a/migrations/20260802000000_razorpay.go b/migrations/20260802000000_razorpay.go new file mode 100644 index 0000000..06daf77 --- /dev/null +++ b/migrations/20260802000000_razorpay.go @@ -0,0 +1,105 @@ +package migrations + +import ( + "database/sql" + + "github.com/pocketbase/pocketbase/core" + pbmigrations "github.com/pocketbase/pocketbase/migrations" + "github.com/pocketbase/pocketbase/tools/types" +) + +func init() { + pbmigrations.Register(func(app core.App) error { + users, err := app.FindCollectionByNameOrId("users") + if err != nil { + return err + } + orders, err := findOrCreateRazorpayTestOrders(app, users.Id) + if err != nil { + return err + } + _, err = findOrCreateRazorpayTestEvents(app, orders.Id) + return err + }, func(app core.App) error { + for _, name := range []string{"razorpay_test_events", "razorpay_test_orders"} { + collection, err := app.FindCollectionByNameOrId(name) + if err != nil { + if err == sql.ErrNoRows { + continue + } + return err + } + if err := app.Delete(collection); err != nil { + return err + } + } + return nil + }) +} + +func findOrCreateRazorpayTestOrders(app core.App, usersID string) (*core.Collection, error) { + if collection, err := app.FindCollectionByNameOrId("razorpay_test_orders"); err == nil { + return collection, nil + } + collection := core.NewBaseCollection("razorpay_test_orders") + lockDomainWrites(collection) + collection.Fields.Add( + &core.NumberField{Name: "amount", OnlyInt: true, Min: types.Pointer(float64(100)), Required: true}, + &core.TextField{Name: "currency", Max: 3, Required: true}, + &core.SelectField{Name: "status", Values: []string{ + "creating", "create_failed", "created", "verification_pending", "authorized", "captured", "failed", "partially_refunded", "refunded", + }, Required: true}, + &core.TextField{Name: "external_id", Max: 255}, + &core.TextField{Name: "idempotency_key", Max: 255, Required: true}, + &core.TextField{Name: "razorpay_order_id", Max: 64}, + &core.TextField{Name: "razorpay_payment_id", Max: 64}, + &core.TextField{Name: "provider_status", Max: 64}, + &core.TextField{Name: "payment_method", Max: 64}, + &core.NumberField{Name: "amount_refunded", OnlyInt: true, Min: types.Pointer(float64(0))}, + &core.TextField{Name: "error", Max: 4096}, + &core.RelationField{Name: "created_by", CollectionId: usersID, MaxSelect: 1}, + &core.DateField{Name: "created_at", Required: true}, + &core.DateField{Name: "signature_verified_at"}, + &core.DateField{Name: "captured_at"}, + &core.DateField{Name: "failed_at"}, + &core.DateField{Name: "last_synced_at"}, + &core.AutodateField{Name: "created", OnCreate: true}, + &core.AutodateField{Name: "updated", OnCreate: true, OnUpdate: true}, + ) + collection.AddIndex("idx_rzp_test_idempotency", true, "idempotency_key", "") + collection.AddIndex("idx_rzp_test_order", true, "razorpay_order_id", "razorpay_order_id != ''") + collection.AddIndex("idx_rzp_test_payment", true, "razorpay_payment_id", "razorpay_payment_id != ''") + collection.AddIndex("idx_rzp_test_status", false, "status,created_at", "") + if err := app.Save(collection); err != nil { + return nil, err + } + return collection, nil +} + +func findOrCreateRazorpayTestEvents(app core.App, ordersID string) (*core.Collection, error) { + if collection, err := app.FindCollectionByNameOrId("razorpay_test_events"); err == nil { + return collection, nil + } + collection := core.NewBaseCollection("razorpay_test_events") + lockDomainWrites(collection) + collection.Fields.Add( + &core.TextField{Name: "event_id", Max: 128, Required: true}, + &core.TextField{Name: "event_type", Max: 128, Required: true}, + &core.RelationField{Name: "test_order", CollectionId: ordersID, MaxSelect: 1}, + &core.TextField{Name: "razorpay_order_id", Max: 64}, + &core.TextField{Name: "razorpay_payment_id", Max: 64}, + &core.SelectField{Name: "status", Values: []string{"processed", "ignored", "failed"}, Required: true}, + &core.TextField{Name: "payload_hash", Max: 64, Required: true}, + &core.DateField{Name: "provider_created_at"}, + &core.DateField{Name: "received_at", Required: true}, + &core.TextField{Name: "error", Max: 4096}, + &core.AutodateField{Name: "created", OnCreate: true}, + ) + collection.AddIndex("idx_rzp_test_event_id", true, "event_id", "") + collection.AddIndex("idx_rzp_test_event_order", false, "test_order,received_at", "test_order != ''") + collection.AddIndex("idx_rzp_test_event_type", false, "event_type,received_at", "") + if err := app.Save(collection); err != nil { + return nil, err + } + return collection, nil +} diff --git a/migrations/migration_test.go b/migrations/migration_test.go index 4993028..124bcbc 100644 --- a/migrations/migration_test.go +++ b/migrations/migration_test.go @@ -14,7 +14,7 @@ func TestDomainCollectionsOnlyExposeReadsToOperatorUsers(t *testing.T) { } defer app.Cleanup() - for _, name := range []string{"payments", "sms_events", "webhook_deliveries", "audit_events", "review_cases", "reconciliation_runs", "reconciliation_entries", "alerts", "refunds"} { + for _, name := range []string{"payments", "sms_events", "webhook_deliveries", "audit_events", "review_cases", "reconciliation_runs", "reconciliation_entries", "alerts", "refunds", "razorpay_test_orders", "razorpay_test_events"} { collection, err := app.FindCollectionByNameOrId(name) if err != nil { t.Fatalf("find %s: %v", name, err) diff --git a/web/src/App.tsx b/web/src/App.tsx index 89a2556..da6c5e3 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -7,8 +7,9 @@ import { Payments } from "./pages/Payments"; import { AuditEvents, SMSEvents, WebhookDeliveries } from "./pages/Records"; import { AlertsPage, ReconciliationPage, RefundsPage, ReviewsPage } from "./pages/Operations"; import { Settings } from "./pages/Settings"; +import { RazorpayTestPage } from "./pages/RazorpayTest"; -const pages: Page[] = ["dashboard", "payments", "reviews", "reconciliation", "sms", "alerts", "refunds", "webhooks", "audit", "settings"]; +const pages: Page[] = ["dashboard", "payments", "reviews", "reconciliation", "sms", "alerts", "refunds", "webhooks", "audit", "razorpay_test", "settings"]; function pageFromHash(): Page { const value = window.location.hash.replace(/^#\/?/, "") as Page; @@ -71,6 +72,7 @@ export function App() { {page === "refunds" && } {page === "webhooks" && } {page === "audit" && } + {page === "razorpay_test" && } {page === "settings" && } ; @@ -79,5 +81,6 @@ export function App() { function label(value: string) { if (value === "sms") return "SMS Events"; if (value === "audit") return "Audit Trail"; + if (value === "razorpay_test") return "Razorpay Test"; return value.charAt(0).toUpperCase() + value.slice(1); } diff --git a/web/src/pages/RazorpayTest.tsx b/web/src/pages/RazorpayTest.tsx new file mode 100644 index 0000000..258c928 --- /dev/null +++ b/web/src/pages/RazorpayTest.tsx @@ -0,0 +1,200 @@ +import { useCallback, useEffect, useState, type FormEvent } from "react"; +import { Badge, formatDate } from "../components/common"; +import { api, pb } from "../pb"; +import type { RazorpayTestConfig, RazorpayTestOrder, RazorpayTestOrderResponse } from "../types"; + +type CheckoutSuccess = { + razorpay_payment_id: string; + razorpay_order_id: string; + razorpay_signature: string; +}; + +type RazorpayOptions = { + key: string; + amount: number; + currency: string; + name: string; + description: string; + order_id: string; + handler: (response: CheckoutSuccess) => void; + modal?: { ondismiss?: () => void }; + retry?: { enabled: boolean }; + theme?: { color: string }; +}; + +declare global { + interface Window { + Razorpay?: new (options: RazorpayOptions) => { open: () => void }; + } +} + +let checkoutLoader: Promise | null = null; + +export function RazorpayTestPage({ notify }: { notify: (value: string) => void }) { + const [config, setConfig] = useState(null); + const [orders, setOrders] = useState([]); + const [amount, setAmount] = useState("1.00"); + const [externalId, setExternalId] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [idempotencyKey, setIdempotencyKey] = useState(() => crypto.randomUUID()); + + const load = useCallback(async () => { + try { + const nextConfig = await api("/api/razorpay/test/config"); + setConfig(nextConfig); + if (nextConfig.enabled) { + const result = await pb.collection("razorpay_test_orders").getList(1, 100, { sort: "-created_at" }); + setOrders(result.items); + } else { + setOrders([]); + } + setError(""); + } catch (err) { + setError(err instanceof Error ? err.message : "Could not load Razorpay test rail"); + } + }, []); + + useEffect(() => { void load(); }, [load]); + useEffect(() => { + if (!config?.enabled) return; + let disposed = false; + let unsubscribe: (() => void) | undefined; + void pb.collection("razorpay_test_orders").subscribe("*", () => void load()).then((fn) => { + if (disposed) void fn(); else unsubscribe = fn; + }); + return () => { disposed = true; unsubscribe?.(); }; + }, [config?.enabled, load]); + + async function create(event: FormEvent) { + event.preventDefault(); + if (!config?.enabled) return; + const amountPaise = parseRupees(amount); + if (amountPaise === null) { + notify("Enter an amount between ₹1.00 and ₹1,00,000.00 with at most two decimal places."); + return; + } + setBusy(true); + try { + const order = await api("/api/razorpay/test/orders", { + method: "POST", + headers: { "Idempotency-Key": idempotencyKey }, + body: JSON.stringify({ amountPaise, externalId: externalId.trim() || undefined }), + }); + await openCheckout(order, async (response) => { + try { + const verified = await api(`/api/razorpay/test/orders/${order.id}/verify`, { + method: "POST", + body: JSON.stringify(response), + }); + notify(verified.status === "captured" ? "Razorpay test payment captured." : `Callback verified; provider status is ${verified.status}.`); + await load(); + } catch (err) { + notify(err instanceof Error ? err.message : "Razorpay callback verification failed."); + } + }, () => notify("Razorpay test checkout closed without a completed payment.")); + setExternalId(""); + setIdempotencyKey(crypto.randomUUID()); + await load(); + } catch (err) { + notify(err instanceof Error ? err.message : "Could not create Razorpay test order."); + } finally { + setBusy(false); + } + } + + async function refresh(order: RazorpayTestOrder) { + setBusy(true); + try { + const updated = await api(`/api/razorpay/test/orders/${order.id}/refresh`, { method: "POST" }); + notify(`Razorpay status refreshed: ${updated.status}.`); + await load(); + } catch (err) { + notify(err instanceof Error ? err.message : "Could not refresh Razorpay status."); + } finally { + setBusy(false); + } + } + + if (error) return

{error}

; + if (!config) return

Loading Razorpay test rail…

; + + return <> +
+
+
+

ISOLATED TEST RAIL

+

Razorpay Test Mode

+

Mock transactions only. This module is separate from PayGate’s SMS/DDM payment records.

+
+ +
+ {!config.enabled ?
+ Disabled + Add Test Mode credentials to a staging deployment and set RAZORPAY_TEST_ENABLED=true. + No Razorpay secret is sent to this browser. +
:
+ + + +
} + {config.enabled &&

Checkout key: {config.keyId}. Use Razorpay’s Test Mode success/failure controls; no real money is deducted.

} +
+ +
+

Test orders

+ {!orders.length ?

No Razorpay test orders yet.

:
+ + {orders.map((order) => + + + + + + + )} +
CreatedAmountLocal / Razorpay IDsStatusMethodAction
{formatDate(order.created_at)}₹{(order.amount / 100).toFixed(2)}{order.id}{order.razorpay_order_id || "Provider order pending"}{order.razorpay_payment_id || "No payment yet"}{order.error && {order.error}}{order.payment_method || "—"}
} +
+ ; +} + +async function openCheckout(order: RazorpayTestOrderResponse, handler: (response: CheckoutSuccess) => void, dismissed: () => void) { + await loadCheckoutScript(); + if (!window.Razorpay) throw new Error("Razorpay Checkout failed to initialize."); + if (!order.razorpayOrderId) throw new Error("The server did not return a Razorpay order id."); + const checkout = new window.Razorpay({ + key: order.keyId, + amount: order.amountPaise, + currency: order.currency, + name: order.displayName, + description: "PayGate isolated test transaction", + order_id: order.razorpayOrderId, + handler, + modal: { ondismiss: dismissed }, + retry: { enabled: true }, + theme: { color: "#d8f36a" }, + }); + checkout.open(); +} + +function loadCheckoutScript(): Promise { + if (window.Razorpay) return Promise.resolve(); + if (checkoutLoader) return checkoutLoader; + checkoutLoader = new Promise((resolve, reject) => { + const script = document.createElement("script"); + script.src = "https://checkout.razorpay.com/v1/checkout.js"; + script.async = true; + script.onload = () => resolve(); + script.onerror = () => reject(new Error("Could not load Razorpay Checkout.")); + document.head.appendChild(script); + }); + return checkoutLoader; +} + +function parseRupees(value: string): number | null { + const normalized = value.trim(); + if (!/^\d+(?:\.\d{1,2})?$/.test(normalized)) return null; + const amount = Number(normalized); + if (!Number.isFinite(amount) || amount < 1 || amount > 100_000) return null; + return Math.round(amount * 100); +} diff --git a/web/src/styles.css b/web/src/styles.css index a4b5b61..10976c4 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -128,3 +128,6 @@ textarea { min-height: 96px; resize: vertical; } .capacity-meter { grid-column: 1 / -1; grid-row: 2; } } @media (max-width: 480px) { .grid.six, .grid.two { grid-template-columns: 1fr; } } +.badge.test, .badge.captured, .badge.authorized { background: #334f36; color: #d8f36a; } +.badge.creating, .badge.created, .badge.verification_pending { background: #34445d; color: #a9ccff; } +.badge.create_failed, .badge.partially_refunded, .badge.refunded { background: #5b4228; color: #ffc56a; } diff --git a/web/src/types.ts b/web/src/types.ts index 49c14a9..22513bb 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -1,6 +1,6 @@ import type { RecordModel } from "pocketbase"; -export type Page = "dashboard" | "payments" | "reviews" | "reconciliation" | "sms" | "alerts" | "refunds" | "webhooks" | "audit" | "settings"; +export type Page = "dashboard" | "payments" | "reviews" | "reconciliation" | "sms" | "alerts" | "refunds" | "webhooks" | "audit" | "razorpay_test" | "settings"; export type Payment = RecordModel & { requested_amount: number; @@ -142,3 +142,43 @@ export type RefundRecord = RecordModel & { completed_at: string; expand?: Record; }; + +export type RazorpayTestConfig = { + enabled: boolean; + keyId: string; + displayName: string; + mode: "test"; +}; + +export type RazorpayTestOrder = RecordModel & { + amount: number; + currency: string; + status: string; + external_id: string; + razorpay_order_id: string; + razorpay_payment_id: string; + provider_status: string; + payment_method: string; + amount_refunded: number; + error: string; + created_at: string; + captured_at: string; +}; + +export type RazorpayTestOrderResponse = { + id: string; + amountPaise: number; + currency: string; + status: string; + externalId: string; + razorpayOrderId: string; + razorpayPaymentId: string; + providerStatus: string; + paymentMethod: string; + amountRefunded: number; + error: string; + createdAt: string; + capturedAt: string; + keyId: string; + displayName: string; +}; From 51c5c73d2e91510416165cdc0a39680037ff8898 Mon Sep 17 00:00:00 2001 From: Phloraxx Date: Sun, 2 Aug 2026 16:11:38 +0000 Subject: [PATCH 2/2] Allow the IEEE portal to proxy Razorpay Test orders --- RAZORPAY_TEST.md | 10 +++++++++ internal/api/razorpay.go | 29 +++++++++++++++--------- internal/api/razorpay_api_test.go | 37 +++++++++++++++++++++++++++++-- 3 files changed, 63 insertions(+), 13 deletions(-) diff --git a/RAZORPAY_TEST.md b/RAZORPAY_TEST.md index 1f9525f..b819f33 100644 --- a/RAZORPAY_TEST.md +++ b/RAZORPAY_TEST.md @@ -84,3 +84,13 @@ Suggested scenarios: - no customer-facing production checkout route; - no automatic migration of Razorpay test orders into normal PayGate payments; - no raw webhook-payload retention. + +## Public IEEE portal proxy + +The approved public website is `https://pay.ieeesahrdaya.com`. The customer browser must not call the isolated Razorpay service directly. The maintained `payment-frontend` Hono server proxies only the customer-safe config/create/status/verify routes with a separate server API key. Razorpay sends the raw signed webhook through the same approved domain: + +```text +https://pay.ieeesahrdaya.com/api/razorpay/test/webhook +``` + +The isolated Razorpay service accepts either an operator session or `PAYGATE_API_KEY` for config/order operations. The webhook remains authenticated exclusively by `X-Razorpay-Signature` over the original raw body. diff --git a/internal/api/razorpay.go b/internal/api/razorpay.go index dc27316..d5e6dfc 100644 --- a/internal/api/razorpay.go +++ b/internal/api/razorpay.go @@ -21,8 +21,8 @@ type razorpayTestVerifyBody struct { } func (a *API) razorpayTestConfig(e *core.RequestEvent) error { - if !a.dashboardAuth(e) { - return e.UnauthorizedError("dashboard authentication is required", nil) + if !a.authorizedWrite(e) { + return e.UnauthorizedError("API key or dashboard authentication is required", nil) } enabled := a.Config.RazorpayTestEnabled && a.RazorpayTest != nil keyID := "" @@ -38,8 +38,8 @@ func (a *API) razorpayTestConfig(e *core.RequestEvent) error { } func (a *API) razorpayTestCreateOrder(e *core.RequestEvent) error { - if !a.dashboardAuth(e) { - return e.UnauthorizedError("dashboard authentication is required", nil) + if !a.authorizedWrite(e) { + return e.UnauthorizedError("API key or dashboard authentication is required", nil) } if !a.razorpayTestAvailable() { return e.NotFoundError("Razorpay test rail is disabled", nil) @@ -50,7 +50,7 @@ func (a *API) razorpayTestCreateOrder(e *core.RequestEvent) error { } record, replayed, err := a.RazorpayTest.Create(e.Request.Context(), razorpaytest.CreateInput{ AmountPaise: body.AmountPaise, ExternalID: body.ExternalID, - IdempotencyKey: strings.TrimSpace(e.Request.Header.Get("Idempotency-Key")), ActorID: e.Auth.Id, + IdempotencyKey: strings.TrimSpace(e.Request.Header.Get("Idempotency-Key")), ActorID: a.razorpayActorID(e), }) if err != nil { return writeDomainError(e, err) @@ -64,8 +64,8 @@ func (a *API) razorpayTestCreateOrder(e *core.RequestEvent) error { } func (a *API) razorpayTestGetOrder(e *core.RequestEvent) error { - if !a.dashboardAuth(e) { - return e.UnauthorizedError("dashboard authentication is required", nil) + if !a.authorizedWrite(e) { + return e.UnauthorizedError("API key or dashboard authentication is required", nil) } if !a.razorpayTestAvailable() { return e.NotFoundError("Razorpay test rail is disabled", nil) @@ -78,8 +78,8 @@ func (a *API) razorpayTestGetOrder(e *core.RequestEvent) error { } func (a *API) razorpayTestVerify(e *core.RequestEvent) error { - if !a.dashboardAuth(e) { - return e.UnauthorizedError("dashboard authentication is required", nil) + if !a.authorizedWrite(e) { + return e.UnauthorizedError("API key or dashboard authentication is required", nil) } if !a.razorpayTestAvailable() { return e.NotFoundError("Razorpay test rail is disabled", nil) @@ -99,8 +99,8 @@ func (a *API) razorpayTestVerify(e *core.RequestEvent) error { } func (a *API) razorpayTestRefresh(e *core.RequestEvent) error { - if !a.dashboardAuth(e) { - return e.UnauthorizedError("dashboard authentication is required", nil) + if !a.authorizedWrite(e) { + return e.UnauthorizedError("API key or dashboard authentication is required", nil) } if !a.razorpayTestAvailable() { return e.NotFoundError("Razorpay test rail is disabled", nil) @@ -133,6 +133,13 @@ func (a *API) razorpayTestWebhook(e *core.RequestEvent) error { return e.JSON(http.StatusOK, result) } +func (a *API) razorpayActorID(e *core.RequestEvent) string { + if e.Auth != nil { + return e.Auth.Id + } + return "" +} + func (a *API) razorpayTestAvailable() bool { return a.Config.RazorpayTestEnabled && a.RazorpayTest != nil } diff --git a/internal/api/razorpay_api_test.go b/internal/api/razorpay_api_test.go index 167f571..a4b526f 100644 --- a/internal/api/razorpay_api_test.go +++ b/internal/api/razorpay_api_test.go @@ -45,6 +45,7 @@ type razorpayAPIFixture struct { app *tests.TestApp server *httptest.Server token string + apiKey string service *razorpaytest.Service provider *apiRazorpayProvider } @@ -65,7 +66,7 @@ func newRazorpayAPIFixture(t *testing.T, enabled bool) *razorpayAPIFixture { } token, _ := operator.NewAuthToken() cfg := config.Config{ - TestMode: true, PaymentTTL: 5, AmountQuarantine: 0, + TestMode: true, APIKey: "razorpay-api-key-1234567890123456", PaymentTTL: 5, AmountQuarantine: 0, RazorpayTestEnabled: enabled, RazorpayTestKeyID: "rzp_test_api", RazorpayTestKeySecret: "checkout-secret-123456", RazorpayTestWebhookSecret: "webhook-secret-123456789012", RazorpayTestDisplayName: "PayGate Test", @@ -90,7 +91,7 @@ func newRazorpayAPIFixture(t *testing.T, enabled bool) *razorpayAPIFixture { mux, _ := serveEvent.Router.BuildMux() server := httptest.NewServer(mux) t.Cleanup(server.Close) - return &razorpayAPIFixture{app: app, server: server, token: token, service: service, provider: provider} + return &razorpayAPIFixture{app: app, server: server, token: token, apiKey: cfg.APIKey, service: service, provider: provider} } func (f *razorpayAPIFixture) request(t *testing.T, method, path, body string, authenticated bool, headers map[string]string) (*http.Response, string) { @@ -117,6 +118,14 @@ func (f *razorpayAPIFixture) request(t *testing.T, method, path, body string, au return res, string(raw) } +func (f *razorpayAPIFixture) apiKeyRequest(t *testing.T, method, path, body string, headers map[string]string) (*http.Response, string) { + if headers == nil { + headers = map[string]string{} + } + headers["Authorization"] = "Bearer " + f.apiKey + return f.request(t, method, path, body, false, headers) +} + func TestRazorpayTestRoutesAreDisabledByDefault(t *testing.T) { fixture := newRazorpayAPIFixture(t, false) res, body := fixture.request(t, http.MethodGet, "/api/razorpay/test/config", "", true, nil) @@ -212,3 +221,27 @@ func TestRazorpayEnabledCSPAllowsOnlyRazorpayCheckoutOrigins(t *testing.T) { t.Fatalf("CSP was weakened: %s", csp) } } + +func TestRazorpayTestRoutesAcceptServerAPIKey(t *testing.T) { + fixture := newRazorpayAPIFixture(t, true) + res, body := fixture.apiKeyRequest(t, http.MethodGet, "/api/razorpay/test/config", "", nil) + if res.StatusCode != http.StatusOK || !strings.Contains(body, `"enabled":true`) { + t.Fatalf("config status=%d body=%s", res.StatusCode, body) + } + res, body = fixture.apiKeyRequest(t, http.MethodPost, "/api/razorpay/test/orders", `{"amountPaise":125,"externalId":"portal-test"}`, map[string]string{"Idempotency-Key": "portal-test"}) + if res.StatusCode != http.StatusCreated || !strings.Contains(body, `"razorpayOrderId":"order_api_test"`) { + t.Fatalf("create status=%d body=%s", res.StatusCode, body) + } + localID := jsonStringField(t, body, "id") + record, err := fixture.app.FindRecordById("razorpay_test_orders", localID) + if err != nil { + t.Fatal(err) + } + if record.GetString("created_by") != "" { + t.Fatalf("API-key order unexpectedly has created_by=%q", record.GetString("created_by")) + } + res, body = fixture.apiKeyRequest(t, http.MethodGet, "/api/razorpay/test/orders/"+localID, "", nil) + if res.StatusCode != http.StatusOK || !strings.Contains(body, `"amountPaise":125`) { + t.Fatalf("get status=%d body=%s", res.StatusCode, body) + } +}