diff --git a/.env.example b/.env.example index ef29159..271ddb9 100644 --- a/.env.example +++ b/.env.example @@ -71,3 +71,11 @@ RAZORPAY_TEST_KEY_ID= RAZORPAY_TEST_KEY_SECRET= RAZORPAY_TEST_WEBHOOK_SECRET= RAZORPAY_TEST_DISPLAY_NAME=PayGate Razorpay Test + + +# Optional isolated Razorpay Live pilot. Initially hard-capped to exactly ₹1. +RAZORPAY_LIVE_ENABLED=false +RAZORPAY_LIVE_KEY_ID= +RAZORPAY_LIVE_KEY_SECRET= +RAZORPAY_LIVE_WEBHOOK_SECRET= +RAZORPAY_LIVE_DISPLAY_NAME=IEEE Sahrdaya Razorpay Live diff --git a/.env.razorpay-live.example b/.env.razorpay-live.example new file mode 100644 index 0000000..93da994 --- /dev/null +++ b/.env.razorpay-live.example @@ -0,0 +1,13 @@ +# Copy to a protected environment file and fill from Razorpay Live Mode. +# The Live Key Secret and webhook secret must never be committed. +RAZORPAY_LIVE_KEY_ID=rzp_live_replace_me +RAZORPAY_LIVE_KEY_SECRET=replace_with_live_key_secret +RAZORPAY_LIVE_WEBHOOK_SECRET=replace_with_a_separate_random_webhook_secret +RAZORPAY_LIVE_DISPLAY_NAME=IEEE Sahrdaya Razorpay Live + +# Separate internal authorization for the public portal proxy. +PAYGATE_API_KEY=replace_with_a_random_live_internal_api_key +SMS_WEBHOOK_SECRET=replace_with_a_random_unused_live_secret +UPI_PAYEE_NAME=IEEE Sahrdaya Razorpay Live +STATEMENT_TIMEZONE=Asia/Kolkata +PAYGATE_RATE_LIMITS_ENABLED=true diff --git a/RAZORPAY_LIVE.md b/RAZORPAY_LIVE.md new file mode 100644 index 0000000..d0436d1 --- /dev/null +++ b/RAZORPAY_LIVE.md @@ -0,0 +1,40 @@ +# Razorpay Live ₹1 Pilot + +This is a separate Live Mode rail. It does not reuse Test Mode collections, +credentials, webhook events, or Docker volume. During the pilot, the backend +accepts only an exact ₹1 order. + +## Required protected values + +```text +RAZORPAY_LIVE_ENABLED=true +RAZORPAY_LIVE_KEY_ID=rzp_live_... +RAZORPAY_LIVE_KEY_SECRET=... +RAZORPAY_LIVE_WEBHOOK_SECRET=... +PAYGATE_API_KEY= +``` + +The public portal reaches the service only over the private Dokploy network. +The service must not publish a host port. + +## Live webhook + +Configure in the Razorpay Dashboard while switched to Live Mode: + +```text +https://pay.ieeesahrdaya.com/api/razorpay/live/webhook +``` + +Subscribe to `payment.authorized`, `payment.captured`, and `payment.failed`. +Use a separate webhook secret, not the API Key Secret. + +## Pilot route + +The portal deliberately does not link this route from the home page: + +```text +https://pay.ieeesahrdaya.com/razorpay-live +``` + +The browser can create only ₹1. The portal and the isolated Live backend both +enforce that cap. Only provider state `captured` is displayed as successful. diff --git a/cmd/payment-api/main.go b/cmd/payment-api/main.go index 63c63a6..ab83f97 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/razorpaylive" "github.com/Phloraxx/payment-api/internal/razorpaytest" "github.com/Phloraxx/payment-api/internal/reconciliation" "github.com/Phloraxx/payment-api/internal/refunds" @@ -70,6 +71,11 @@ func main() { razorpayClient := razorpaytest.NewClient(cfg.RazorpayTestKeyID, cfg.RazorpayTestKeySecret) razorpayTestService = razorpaytest.NewService(app, razorpayClient, cfg.RazorpayTestKeyID, cfg.RazorpayTestKeySecret, cfg.RazorpayTestWebhookSecret, cfg.RazorpayTestDisplayName) } + var razorpayLiveService *razorpaylive.Service + if cfg.RazorpayLiveEnabled { + razorpayClient := razorpaylive.NewClient(cfg.RazorpayLiveKeyID, cfg.RazorpayLiveKeySecret) + razorpayLiveService = razorpaylive.NewService(app, razorpayClient, cfg.RazorpayLiveKeyID, cfg.RazorpayLiveKeySecret, cfg.RazorpayLiveWebhookSecret, cfg.RazorpayLiveDisplayName) + } retentionService := retention.NewService(app, cfg) backupService := backups.NewService(app, cfg, alertService) backupService.RegisterHooks() @@ -84,6 +90,7 @@ func main() { apiService.Refunds = refundService apiService.Backups = backupService apiService.RazorpayTest = razorpayTestService + apiService.RazorpayLive = razorpayLiveService apiService.Register(app) registerPairCommand(app, cfg, gmessagesLogger) registerHealthcheckCommand(app) diff --git a/docker-compose.razorpay-live.yml b/docker-compose.razorpay-live.yml new file mode 100644 index 0000000..d818eec --- /dev/null +++ b/docker-compose.razorpay-live.yml @@ -0,0 +1,20 @@ +services: + paygate-razorpay-live: + build: + context: . + env_file: + - .env.razorpay-live + 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_LIVE_ENABLED: "true" + volumes: + - paygate_razorpay_live_data:/app/pb_data + restart: unless-stopped + +volumes: + paygate_razorpay_live_data: diff --git a/internal/api/api.go b/internal/api/api.go index a840506..69cc532 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/razorpaylive" "github.com/Phloraxx/payment-api/internal/razorpaytest" "github.com/Phloraxx/payment-api/internal/reconciliation" "github.com/Phloraxx/payment-api/internal/refunds" @@ -36,6 +37,7 @@ const ( maxRefundRequestBytes int64 = (1 << 20) + (64 << 10) maxStatementRequestBytes int64 = reconciliation.MaxFileBytes + (1 << 20) maxRazorpayTestRequestBytes int64 = 1 << 20 + maxRazorpayLiveRequestBytes int64 = 1 << 20 ) type API struct { @@ -49,6 +51,7 @@ type API struct { Refunds *refunds.Service Backups *backups.Service RazorpayTest *razorpaytest.Service + RazorpayLive *razorpaylive.Service } func New(cfg config.Config, paymentService *payments.Service, smsService *sms.Service, manager *gmessages.Manager) *API { @@ -80,6 +83,12 @@ func (a *API) Register(app core.App) { 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/razorpay/live/config", a.razorpayLiveConfig) + e.Router.POST("/api/razorpay/live/orders", a.razorpayLiveCreateOrder).Bind(apis.BodyLimit(maxRazorpayLiveRequestBytes)) + e.Router.GET("/api/razorpay/live/orders/{id}", a.razorpayLiveGetOrder) + e.Router.POST("/api/razorpay/live/orders/{id}/verify", a.razorpayLiveVerify).Bind(apis.BodyLimit(maxRazorpayLiveRequestBytes)) + e.Router.POST("/api/razorpay/live/orders/{id}/refresh", a.razorpayLiveRefresh) + e.Router.POST("/api/razorpay/live/webhook", a.razorpayLiveWebhook).Bind(apis.BodyLimit(maxRazorpayLiveRequestBytes)) 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)) diff --git a/internal/api/razorpay.go b/internal/api/razorpay.go index d5e6dfc..8f5d2b6 100644 --- a/internal/api/razorpay.go +++ b/internal/api/razorpay.go @@ -147,7 +147,7 @@ func (a *API) razorpayTestAvailable() bool { 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 { + if a.Config.RazorpayTestEnabled || a.Config.RazorpayLiveEnabled { 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) diff --git a/internal/api/razorpay_live.go b/internal/api/razorpay_live.go new file mode 100644 index 0000000..a6f4188 --- /dev/null +++ b/internal/api/razorpay_live.go @@ -0,0 +1,138 @@ +package api + +import ( + "io" + "net/http" + "strings" + + "github.com/Phloraxx/payment-api/internal/razorpaylive" + "github.com/pocketbase/pocketbase/core" +) + +type razorpayLiveCreateBody struct { + AmountPaise int64 `json:"amountPaise"` + ExternalID string `json:"externalId"` +} + +type razorpayLiveVerifyBody struct { + RazorpayOrderID string `json:"razorpay_order_id"` + RazorpayPaymentID string `json:"razorpay_payment_id"` + RazorpaySignature string `json:"razorpay_signature"` +} + +func (a *API) razorpayLiveConfig(e *core.RequestEvent) error { + if !a.authorizedWrite(e) { + return e.UnauthorizedError("API key or dashboard authentication is required", nil) + } + enabled := a.Config.RazorpayLiveEnabled && a.RazorpayLive != nil + keyID := "" + if enabled { + keyID = a.Config.RazorpayLiveKeyID + } + return e.JSON(http.StatusOK, map[string]any{ + "enabled": enabled, + "keyId": keyID, + "displayName": a.Config.RazorpayLiveDisplayName, + "mode": "live", + }) +} + +func (a *API) razorpayLiveCreateOrder(e *core.RequestEvent) error { + if !a.authorizedWrite(e) { + return e.UnauthorizedError("API key or dashboard authentication is required", nil) + } + if !a.razorpayLiveAvailable() { + return e.NotFoundError("Razorpay live rail is disabled", nil) + } + var body razorpayLiveCreateBody + if err := decodeJSON(e, &body); err != nil { + return e.BadRequestError("invalid JSON body", err) + } + record, replayed, err := a.RazorpayLive.Create(e.Request.Context(), razorpaylive.CreateInput{ + AmountPaise: body.AmountPaise, ExternalID: body.ExternalID, + IdempotencyKey: strings.TrimSpace(e.Request.Header.Get("Idempotency-Key")), ActorID: a.razorpayActorID(e), + }) + 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, razorpaylive.OrderResponse(record, a.Config.RazorpayLiveKeyID, a.Config.RazorpayLiveDisplayName)) +} + +func (a *API) razorpayLiveGetOrder(e *core.RequestEvent) error { + if !a.authorizedWrite(e) { + return e.UnauthorizedError("API key or dashboard authentication is required", nil) + } + if !a.razorpayLiveAvailable() { + return e.NotFoundError("Razorpay live rail is disabled", nil) + } + record, err := a.RazorpayLive.Get(e.Request.PathValue("id")) + if err != nil { + return writeDomainError(e, err) + } + return e.JSON(http.StatusOK, razorpaylive.OrderResponse(record, a.Config.RazorpayLiveKeyID, a.Config.RazorpayLiveDisplayName)) +} + +func (a *API) razorpayLiveVerify(e *core.RequestEvent) error { + if !a.authorizedWrite(e) { + return e.UnauthorizedError("API key or dashboard authentication is required", nil) + } + if !a.razorpayLiveAvailable() { + return e.NotFoundError("Razorpay live rail is disabled", nil) + } + var body razorpayLiveVerifyBody + if err := decodeJSON(e, &body); err != nil { + return e.BadRequestError("invalid JSON body", err) + } + record, err := a.RazorpayLive.Verify(e.Request.Context(), razorpaylive.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, razorpaylive.OrderResponse(record, a.Config.RazorpayLiveKeyID, a.Config.RazorpayLiveDisplayName)) +} + +func (a *API) razorpayLiveRefresh(e *core.RequestEvent) error { + if !a.authorizedWrite(e) { + return e.UnauthorizedError("API key or dashboard authentication is required", nil) + } + if !a.razorpayLiveAvailable() { + return e.NotFoundError("Razorpay live rail is disabled", nil) + } + record, err := a.RazorpayLive.Refresh(e.Request.Context(), e.Request.PathValue("id")) + if err != nil { + return writeDomainError(e, err) + } + return e.JSON(http.StatusOK, razorpaylive.OrderResponse(record, a.Config.RazorpayLiveKeyID, a.Config.RazorpayLiveDisplayName)) +} + +func (a *API) razorpayLiveWebhook(e *core.RequestEvent) error { + if !a.razorpayLiveAvailable() { + return e.NotFoundError("Razorpay live rail is disabled", nil) + } + raw, err := io.ReadAll(io.LimitReader(e.Request.Body, maxRazorpayLiveRequestBytes+1)) + if err != nil { + return e.BadRequestError("failed to read Razorpay webhook", err) + } + if len(raw) > int(maxRazorpayLiveRequestBytes) { + return e.JSON(http.StatusRequestEntityTooLarge, map[string]any{"error": map[string]any{"code": "RAZORPAY_LIVE_WEBHOOK_TOO_LARGE", "message": "webhook exceeds 1 MiB"}}) + } + result, err := a.RazorpayLive.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) razorpayLiveAvailable() bool { + return a.Config.RazorpayLiveEnabled && a.RazorpayLive != nil +} diff --git a/internal/api/razorpay_live_api_test.go b/internal/api/razorpay_live_api_test.go new file mode 100644 index 0000000..9109d6c --- /dev/null +++ b/internal/api/razorpay_live_api_test.go @@ -0,0 +1,247 @@ +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/razorpaylive" + "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 apiRazorpayLiveProvider struct { + order razorpaylive.ProviderOrder + payment razorpaylive.ProviderPayment +} + +func (p *apiRazorpayLiveProvider) CreateOrder(_ context.Context, amount int64, receipt string) (razorpaylive.ProviderOrder, error) { + order := p.order + if order.ID == "" { + order = razorpaylive.ProviderOrder{ID: "order_api_live", Amount: amount, Currency: "INR", Receipt: receipt, Status: "created"} + } + return order, nil +} + +func (p *apiRazorpayLiveProvider) FetchPayment(_ context.Context, paymentID string) (razorpaylive.ProviderPayment, error) { + payment := p.payment + payment.ID = paymentID + return payment, nil +} + +type razorpayLiveAPIFixture struct { + app *tests.TestApp + server *httptest.Server + token string + apiKey string + service *razorpaylive.Service + provider *apiRazorpayLiveProvider +} + +func newRazorpayLiveAPIFixture(t *testing.T, enabled bool) *razorpayLiveAPIFixture { + 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, APIKey: "razorpay-live-api-key-1234567890123456", PaymentTTL: 5, AmountQuarantine: 0, + RazorpayLiveEnabled: enabled, RazorpayLiveKeyID: "rzp_live_api", + RazorpayLiveKeySecret: "checkout-secret-123456", RazorpayLiveWebhookSecret: "webhook-secret-123456789012", + RazorpayLiveDisplayName: "PayGate Live", + } + paymentService := payments.NewService(app, cfg, nil) + smsService := sms.NewService(app, paymentService) + provider := &apiRazorpayLiveProvider{} + service := razorpaylive.NewService(app, provider, cfg.RazorpayLiveKeyID, cfg.RazorpayLiveKeySecret, cfg.RazorpayLiveWebhookSecret, cfg.RazorpayLiveDisplayName) + apiService := New(cfg, paymentService, smsService, nil) + if enabled { + apiService.RazorpayLive = 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 &razorpayLiveAPIFixture{app: app, server: server, token: token, apiKey: cfg.APIKey, service: service, provider: provider} +} + +func (f *razorpayLiveAPIFixture) 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 (f *razorpayLiveAPIFixture) 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 TestRazorpayLiveRoutesAreDisabledByDefault(t *testing.T) { + fixture := newRazorpayLiveAPIFixture(t, false) + res, body := fixture.request(t, http.MethodGet, "/api/razorpay/live/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/live/orders", `{"amountPaise":100}`, true, map[string]string{"Idempotency-Key": "disabled"}) + if res.StatusCode != http.StatusNotFound { + t.Fatalf("disabled create status=%d", res.StatusCode) + } +} + +func TestRazorpayLiveHTTPCreateVerifyAndAuth(t *testing.T) { + fixture := newRazorpayLiveAPIFixture(t, true) + res, _ := fixture.request(t, http.MethodPost, "/api/razorpay/live/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/live/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_live"`) { + t.Fatalf("create status=%d body=%s", res.StatusCode, body) + } + localID := jsonLiveStringField(t, body, "id") + fixture.provider.payment = razorpaylive.ProviderPayment{OrderID: "order_api_live", Amount: 100, Currency: "INR", Status: "captured", Method: "upi", Captured: true} + verifyBody := `{"razorpay_order_id":"order_api_live","razorpay_payment_id":"pay_api_test","razorpay_signature":"bad"}` + res, _ = fixture.request(t, http.MethodPost, "/api/razorpay/live/orders/"+localID+"/verify", verifyBody, true, nil) + if res.StatusCode != http.StatusBadRequest { + t.Fatalf("tampered verify status=%d", res.StatusCode) + } + signature := apiLiveCheckoutSignature("checkout-secret-123456", "order_api_live", "pay_api_test") + verifyBody = `{"razorpay_order_id":"order_api_live","razorpay_payment_id":"pay_api_test","razorpay_signature":"` + signature + `"}` + res, body = fixture.request(t, http.MethodPost, "/api/razorpay/live/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 TestRazorpayLiveWebhookRequiresSignatureAndDeduplicates(t *testing.T) { + fixture := newRazorpayLiveAPIFixture(t, true) + res, createBody := fixture.request(t, http.MethodPost, "/api/razorpay/live/orders", `{"amountPaise":100}`, 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_live","amount":100,"currency":"INR","status":"captured","method":"upi","captured":true}}}}` + res, _ = fixture.request(t, http.MethodPost, "/api/razorpay/live/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 := razorpaylive.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/live/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/live/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 apiLiveCheckoutSignature(secret, orderID, paymentID string) string { + mac := hmac.New(sha256.New, []byte(secret)) + _, _ = mac.Write([]byte(orderID + "|" + paymentID)) + return hex.EncodeToString(mac.Sum(nil)) +} + +func jsonLiveStringField(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 TestRazorpayLiveEnabledCSPAllowsOnlyRazorpayCheckoutOrigins(t *testing.T) { + fixture := newRazorpayLiveAPIFixture(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) + } +} + +func TestRazorpayLiveRoutesAcceptServerAPIKey(t *testing.T) { + fixture := newRazorpayLiveAPIFixture(t, true) + res, body := fixture.apiKeyRequest(t, http.MethodGet, "/api/razorpay/live/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/live/orders", `{"amountPaise":100,"externalId":"portal-live"}`, map[string]string{"Idempotency-Key": "portal-live"}) + if res.StatusCode != http.StatusCreated || !strings.Contains(body, `"razorpayOrderId":"order_api_live"`) { + t.Fatalf("create status=%d body=%s", res.StatusCode, body) + } + localID := jsonLiveStringField(t, body, "id") + record, err := fixture.app.FindRecordById("razorpay_live_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/live/orders/"+localID, "", nil) + if res.StatusCode != http.StatusOK || !strings.Contains(body, `"amountPaise":100`) { + t.Fatalf("get status=%d body=%s", res.StatusCode, body) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 66daffb..7b5a69b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -50,6 +50,11 @@ type Config struct { RazorpayTestKeySecret string RazorpayTestWebhookSecret string RazorpayTestDisplayName string + RazorpayLiveEnabled bool + RazorpayLiveKeyID string + RazorpayLiveKeySecret string + RazorpayLiveWebhookSecret string + RazorpayLiveDisplayName string } func Load() (Config, error) { @@ -117,6 +122,10 @@ func Load() (Config, error) { if err != nil { return Config{}, err } + razorpayLiveEnabled, err := boolEnv("RAZORPAY_LIVE_ENABLED", false) + if err != nil { + return Config{}, err + } dataDir := strings.TrimSpace(env("PB_DATA_DIR", "./pb_data")) cfg := Config{ @@ -155,6 +164,11 @@ func Load() (Config, error) { 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")), + RazorpayLiveEnabled: razorpayLiveEnabled, + RazorpayLiveKeyID: strings.TrimSpace(os.Getenv("RAZORPAY_LIVE_KEY_ID")), + RazorpayLiveKeySecret: strings.TrimSpace(os.Getenv("RAZORPAY_LIVE_KEY_SECRET")), + RazorpayLiveWebhookSecret: strings.TrimSpace(os.Getenv("RAZORPAY_LIVE_WEBHOOK_SECRET")), + RazorpayLiveDisplayName: strings.TrimSpace(env("RAZORPAY_LIVE_DISPLAY_NAME", "IEEE Sahrdaya Razorpay Live")), } cfg.GMessagesSessionPath = strings.TrimSpace(os.Getenv("GMESSAGES_SESSION_PATH")) if cfg.GMessagesSessionPath == "" { @@ -244,6 +258,20 @@ func (c Config) ValidateServe() error { return errors.New("RAZORPAY_TEST_DISPLAY_NAME must be between 1 and 128 characters") } } + if c.RazorpayLiveEnabled { + if !strings.HasPrefix(c.RazorpayLiveKeyID, "rzp_live_") { + return errors.New("RAZORPAY_LIVE_KEY_ID must be a Live Mode key beginning with rzp_live_") + } + if len(c.RazorpayLiveKeySecret) < 16 { + return errors.New("RAZORPAY_LIVE_KEY_SECRET must be at least 16 characters") + } + if len(c.RazorpayLiveWebhookSecret) < minPrimarySecretLength { + return fmt.Errorf("RAZORPAY_LIVE_WEBHOOK_SECRET must be at least %d characters", minPrimarySecretLength) + } + if c.RazorpayLiveDisplayName == "" || len(c.RazorpayLiveDisplayName) > 128 { + return errors.New("RAZORPAY_LIVE_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/razorpaylive/client.go b/internal/razorpaylive/client.go new file mode 100644 index 0000000..92d2e82 --- /dev/null +++ b/internal/razorpaylive/client.go @@ -0,0 +1,161 @@ +package razorpaylive + +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_live"}, + } + 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/razorpaylive/client_test.go b/internal/razorpaylive/client_test.go new file mode 100644 index 0000000..a3ea584 --- /dev/null +++ b/internal/razorpaylive/client_test.go @@ -0,0 +1,50 @@ +package razorpaylive + +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_live_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_live_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_live_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/razorpaylive/service.go b/internal/razorpaylive/service.go new file mode 100644 index 0000000..0e233df --- /dev/null +++ b/internal/razorpaylive/service.go @@ -0,0 +1,466 @@ +package razorpaylive + +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 { + return nil, false, domain.New("RAZORPAY_LIVE_INVALID_AMOUNT", "live pilot amount must be exactly ₹1", 400) + } + if input.IdempotencyKey == "" || len(input.IdempotencyKey) > 255 { + return nil, false, domain.New("RAZORPAY_LIVE_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_live_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_LIVE_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_LIVE_CREATE_STATE_UNKNOWN", "the previous provider-order attempt did not complete cleanly; inspect the Razorpay Live Dashboard using the local receipt before starting a new attempt", 409) + domainErr.Details = map[string]any{"localOrderId": existing.Id, "receipt": "pgl_" + 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_live_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_live_orders", "idempotency_key", input.IdempotencyKey); findErr == nil { + return existing, true, nil + } + return nil, false, err + } + + providerOrder, err := s.Client.CreateOrder(ctx, input.AmountPaise, "pgl_"+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_LIVE_CREATE_FAILED", "Razorpay live 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_live_orders", strings.TrimSpace(localOrderID)) + if errors.Is(err, sql.ErrNoRows) { + return nil, domain.New("RAZORPAY_LIVE_ORDER_NOT_FOUND", "Razorpay live 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_LIVE_ORDER_MISMATCH", "checkout order id does not match the server-created order", 400) + } + if !strings.HasPrefix(input.RazorpayPaymentID, "pay_") { + return nil, domain.New("RAZORPAY_LIVE_INVALID_PAYMENT", "invalid Razorpay payment id", 400) + } + if !verifyHexHMAC(s.KeySecret, providerOrderID+"|"+input.RazorpayPaymentID, input.RazorpaySignature) { + return nil, domain.New("RAZORPAY_LIVE_SIGNATURE_INVALID", "Razorpay checkout signature verification failed", 400) + } + + err = s.App.RunInTransaction(func(tx core.App) error { + current, err := tx.FindRecordById("razorpay_live_orders", record.Id) + if err != nil { + return err + } + if existing := current.GetString("razorpay_payment_id"); existing != "" && existing != input.RazorpayPaymentID { + return domain.New("RAZORPAY_LIVE_PAYMENT_CONFLICT", "the order is already linked to another Razorpay payment", 409) + } + if other, findErr := tx.FindFirstRecordByData("razorpay_live_orders", "razorpay_payment_id", input.RazorpayPaymentID); findErr == nil && other.Id != current.Id { + return domain.New("RAZORPAY_LIVE_PAYMENT_CONFLICT", "the Razorpay payment is already linked to another live 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_LIVE_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_LIVE_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_LIVE_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_LIVE_EVENT_ID_REQUIRED", "X-Razorpay-Event-Id is required", 400) + } + if len(raw) == 0 || len(raw) > maxWebhookBytes { + return WebhookResult{}, domain.New("RAZORPAY_LIVE_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_LIVE_WEBHOOK_SIGNATURE_INVALID", "invalid Razorpay webhook signature", 401) + } + hashBytes := sha256.Sum256(raw) + payloadHash := hex.EncodeToString(hashBytes[:]) + if existing, err := s.App.FindFirstRecordByData("razorpay_live_events", "event_id", eventID); err == nil { + if existing.GetString("payload_hash") != payloadHash { + return WebhookResult{}, domain.New("RAZORPAY_LIVE_EVENT_ID_CONFLICT", "the Razorpay event id was already used with a different payload", 409) + } + return WebhookResult{Duplicate: true, EventID: eventID, OrderID: existing.GetString("live_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_LIVE_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_live_events", "event_id", eventID); err == nil { + if existing.GetString("payload_hash") != payloadHash { + return domain.New("RAZORPAY_LIVE_EVENT_ID_CONFLICT", "the Razorpay event id was already used with a different payload", 409) + } + result.Duplicate = true + result.OrderID = existing.GetString("live_order") + result.Status = existing.GetString("status") + return nil + } else if !errors.Is(err, sql.ErrNoRows) { + return err + } + collection, err := tx.FindCollectionByNameOrId("razorpay_live_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_live_orders", "razorpay_order_id", payment.OrderID) + if errors.Is(findErr, sql.ErrNoRows) { + event.Set("status", "ignored") + event.Set("error", "No local Razorpay live order matches this event") + result.Ignored = true + result.Status = "ignored" + return tx.Save(event) + } + if findErr != nil { + return findErr + } + event.Set("live_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_live_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_LIVE_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_LIVE_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_LIVE_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/razorpaylive/service_test.go b/internal/razorpaylive/service_test.go new file mode 100644 index 0000000..9940469 --- /dev/null +++ b/internal/razorpaylive/service_test.go @@ -0,0 +1,239 @@ +package razorpaylive + +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_live_key", "checkout-secret-123456", "webhook-secret-123456789012", "PayGate Live") + service.Now = func() time.Time { return now } + return service, client, app, &now +} + +func TestCreateRejectsAnyAmountOtherThanOneRupee(t *testing.T) { + service, client, _, _ := testService(t) + for _, amount := range []int64{1, 99, 101, 200} { + _, _, err := service.Create(context.Background(), CreateInput{AmountPaise: amount, IdempotencyKey: "invalid-amount"}) + var domainErr *domain.Error + if !errors.As(err, &domainErr) || domainErr.Code != "RAZORPAY_LIVE_INVALID_AMOUNT" { + t.Fatalf("amount=%d error=%v", amount, err) + } + } + if client.createCalls != 0 { + t.Fatalf("provider create calls=%d", client.createCalls) + } +} + +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: 100, ExternalID: "order-2", IdempotencyKey: "idem-1"}) + var domainErr *domain.Error + if !errors.As(err, &domainErr) || domainErr.Code != "RAZORPAY_LIVE_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: 100, 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: 100, + 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_LIVE_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: 100, 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: 100, + 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: 100, + 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_live_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_live_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: 100, 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: 101, + Currency: "INR", Status: "captured", + }) + _, err = service.IngestWebhook("evt_invalid_sig", "invalid", body) + var domainErr *domain.Error + if !errors.As(err, &domainErr) || domainErr.Code != "RAZORPAY_LIVE_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_live_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_LIVE_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_LIVE_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: 100, 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: 100, 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: 100, 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_LIVE_EVENT_ID_CONFLICT" { + t.Fatalf("error=%v", err) + } +} diff --git a/migrations/20260803000000_razorpay_live.go b/migrations/20260803000000_razorpay_live.go new file mode 100644 index 0000000..32eefcf --- /dev/null +++ b/migrations/20260803000000_razorpay_live.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 := findOrCreateRazorpayLiveOrders(app, users.Id) + if err != nil { + return err + } + _, err = findOrCreateRazorpayLiveEvents(app, orders.Id) + return err + }, func(app core.App) error { + for _, name := range []string{"razorpay_live_events", "razorpay_live_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 findOrCreateRazorpayLiveOrders(app core.App, usersID string) (*core.Collection, error) { + if collection, err := app.FindCollectionByNameOrId("razorpay_live_orders"); err == nil { + return collection, nil + } + collection := core.NewBaseCollection("razorpay_live_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_live_idempotency", true, "idempotency_key", "") + collection.AddIndex("idx_rzp_live_order", true, "razorpay_order_id", "razorpay_order_id != ''") + collection.AddIndex("idx_rzp_live_payment", true, "razorpay_payment_id", "razorpay_payment_id != ''") + collection.AddIndex("idx_rzp_live_status", false, "status,created_at", "") + if err := app.Save(collection); err != nil { + return nil, err + } + return collection, nil +} + +func findOrCreateRazorpayLiveEvents(app core.App, ordersID string) (*core.Collection, error) { + if collection, err := app.FindCollectionByNameOrId("razorpay_live_events"); err == nil { + return collection, nil + } + collection := core.NewBaseCollection("razorpay_live_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: "live_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_live_event_id", true, "event_id", "") + collection.AddIndex("idx_rzp_live_event_order", false, "live_order,received_at", "live_order != ''") + collection.AddIndex("idx_rzp_live_event_type", false, "event_type,received_at", "") + if err := app.Save(collection); err != nil { + return nil, err + } + return collection, nil +}