Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
13 changes: 13 additions & 0 deletions .env.razorpay-test.example
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,5 @@ tmp/
.vercel/
.dev.vars
.wrangler/

.env.razorpay-test
23 changes: 23 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
96 changes: 96 additions & 0 deletions RAZORPAY_TEST.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# 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://<staging-host>/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.

## 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.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
9 changes: 9 additions & 0 deletions cmd/payment-api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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()
Expand All @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions cmd/payment-api/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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])
}
Expand Down
22 changes: 22 additions & 0 deletions docker-compose.razorpay-test.yml
Original file line number Diff line number Diff line change
@@ -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:
34 changes: 17 additions & 17 deletions internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -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))
Expand All @@ -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()
Expand Down Expand Up @@ -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(),
})
}
Expand Down Expand Up @@ -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
Expand Down
Loading