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
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
13 changes: 13 additions & 0 deletions .env.razorpay-live.example
Original file line number Diff line number Diff line change
@@ -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
40 changes: 40 additions & 0 deletions RAZORPAY_LIVE.md
Original file line number Diff line number Diff line change
@@ -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=<separate internal portal 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.
7 changes: 7 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/razorpaylive"
"github.com/Phloraxx/payment-api/internal/razorpaytest"
"github.com/Phloraxx/payment-api/internal/reconciliation"
"github.com/Phloraxx/payment-api/internal/refunds"
Expand Down Expand Up @@ -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()
Expand All @@ -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)
Expand Down
20 changes: 20 additions & 0 deletions docker-compose.razorpay-live.yml
Original file line number Diff line number Diff line change
@@ -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:
9 changes: 9 additions & 0 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/razorpaylive"
"github.com/Phloraxx/payment-api/internal/razorpaytest"
"github.com/Phloraxx/payment-api/internal/reconciliation"
"github.com/Phloraxx/payment-api/internal/refunds"
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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))
Expand Down
2 changes: 1 addition & 1 deletion internal/api/razorpay.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
138 changes: 138 additions & 0 deletions internal/api/razorpay_live.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading