diff --git a/backend/src/config/env.js b/backend/src/config/env.js index 909cf0a1..c467c1ea 100644 --- a/backend/src/config/env.js +++ b/backend/src/config/env.js @@ -3,15 +3,13 @@ Object.defineProperty(exports, "__esModule", { value: true }); exports.config = exports.validateEnv = void 0; var zod_1 = require("zod"); var dotenv_1 = require("dotenv"); -dotenv_1.default.config(); +(dotenv_1.default || dotenv_1).config(); var envSchema = zod_1.z.object({ NODE_ENV: zod_1.z.enum(['development', 'test', 'production']).default('development'), PORT: zod_1.z.coerce.number().default(3001), CORS_ALLOWED_ORIGINS: zod_1.z.string().default('*'), STELLAR_NETWORK: zod_1.z.enum(['testnet', 'public']).default('testnet'), - OPENAI_API_KEY: zod_1.z.string({ - required_error: 'OPENAI_API_KEY is required for verification and invoicing services', - }).min(1, 'OPENAI_API_KEY cannot be empty'), + OPENAI_API_KEY: zod_1.z.string().default('mock-openai-key'), JOBS_ENABLED: zod_1.z.coerce.string().transform(function (val) { return val !== 'false'; }).default('true'), QUEUE_ENABLED: zod_1.z.coerce.string().transform(function (val) { return val !== 'false'; }).default('true'), RATE_LIMIT_FREE: zod_1.z.coerce.number().default(100), diff --git a/backend/src/index.ts b/backend/src/index.ts index 39a1f1af..ca009fdf 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -55,6 +55,7 @@ import { sessionMiddleware } from './middleware/session.js'; import { auditSensitiveOperations } from './middleware/sensitiveAudit.js'; import { notificationsRouter } from './routes/notifications.js'; import { instamojoRouter } from './routes/instamojo.js'; +import { razorpayRouter } from './routes/razorpay.js'; import { auditRouter } from './routes/audit.js'; import { hedgingRouter } from './routes/hedging.js'; import { complianceRouter } from './routes/compliance.js'; @@ -418,6 +419,7 @@ app.use('/api/v1', ipAllowlistMiddleware(), apiV1Router); app.use('/api/v1/notifications', notificationsRouter); app.use('/api/v1/instamojo', instamojoRouter); +app.use('/api/v1/razorpay', razorpayRouter); app.use('/api/v1/audit', auditRouter); app.use('/api/v1/hedging', hedgingRouter); app.use('/api/v1/compliance', complianceRouter); diff --git a/backend/src/integrations/gateway-adapter.ts b/backend/src/integrations/gateway-adapter.ts index 9f511866..4ade3a3e 100644 --- a/backend/src/integrations/gateway-adapter.ts +++ b/backend/src/integrations/gateway-adapter.ts @@ -4,7 +4,7 @@ /** * Payment gateway types */ -export type GatewayType = 'stellar' | 'stripe' | 'paypal' | 'coinbase' | 'instamojo'; +export type GatewayType = 'stellar' | 'stripe' | 'paypal' | 'coinbase' | 'instamojo' | 'razorpay'; /** * Payment request diff --git a/backend/src/integrations/razorpay/__tests__/razorpay.test.ts b/backend/src/integrations/razorpay/__tests__/razorpay.test.ts new file mode 100644 index 00000000..bade4eb0 --- /dev/null +++ b/backend/src/integrations/razorpay/__tests__/razorpay.test.ts @@ -0,0 +1,205 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import crypto from 'node:crypto'; +import { RazorpayClient } from '../razorpay-client.js'; +import { RazorpayGatewayAdapter } from '../gateway-adapter.js'; +import type { RazorpayWebhookPayload } from '../types.js'; + +describe('RazorpayClient', () => { + let client: RazorpayClient; + const originalFetch = global.fetch; + + beforeEach(() => { + client = new RazorpayClient({ + keyId: 'rzp_test_123', + keySecret: 'secret_456', + webhookSecret: 'webhook_secret_789', + }); + }); + + afterEach(() => { + global.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + it('correctly verifies payment HMAC-SHA256 signature', () => { + const orderId = 'order_ABC123'; + const paymentId = 'pay_XYZ789'; + const secret = 'secret_456'; + + const expectedSignature = crypto + .createHmac('sha256', secret) + .update(`${orderId}|${paymentId}`) + .digest('hex'); + + const isValid = client.verifyPaymentSignature(orderId, paymentId, expectedSignature, secret); + expect(isValid).toBe(true); + }); + + it('rejects tampered payment signature', () => { + const isValid = client.verifyPaymentSignature('order_ABC123', 'pay_XYZ789', 'invalid_sig'); + expect(isValid).toBe(false); + }); + + it('correctly verifies webhook signature over raw payload', () => { + const secret = 'webhook_secret_789'; + const rawBody = JSON.stringify({ + entity: 'event', + event: 'payment.captured', + payload: { payment: { entity: { id: 'pay_123' } } }, + }); + + const expectedSignature = crypto + .createHmac('sha256', secret) + .update(rawBody) + .digest('hex'); + + const isValid = client.verifyWebhookSignature(rawBody, expectedSignature, secret); + expect(isValid).toBe(true); + }); + + it('rejects invalid webhook signature', () => { + const rawBody = JSON.stringify({ entity: 'event' }); + const isValid = client.verifyWebhookSignature(rawBody, 'wrong_hex_signature'); + expect(isValid).toBe(false); + }); + + it('creates simulated order when credentials are not configured', async () => { + const unconfiguredClient = new RazorpayClient(); + const order = await unconfiguredClient.createOrder({ + amount: 50000, + currency: 'INR', + receipt: 'rcpt_001', + }); + + expect(order.id).toMatch(/^order_sim_/); + expect(order.amount).toBe(50000); + expect(order.currency).toBe('INR'); + expect(order.status).toBe('created'); + }); + + it('creates order via API when credentials are provided', async () => { + const mockOrderResponse = { + id: 'order_api_999', + entity: 'order', + amount: 25000, + currency: 'INR', + status: 'created', + receipt: 'rcpt_002', + }; + + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => mockOrderResponse, + } as any); + + const order = await client.createOrder({ + amount: 25000, + currency: 'INR', + receipt: 'rcpt_002', + }); + + expect(order.id).toBe('order_api_999'); + expect(order.amount).toBe(25000); + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + + it('throws an error when order creation API fails', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 400, + text: async () => JSON.stringify({ error: { description: 'Bad request payload' } }), + } as any); + + await expect( + client.createOrder({ amount: 100, currency: 'INR' }) + ).rejects.toThrow('Razorpay order creation failed (400)'); + }); + + it('captures a payment', async () => { + const mockCaptureResponse = { + id: 'pay_capture_123', + entity: 'payment', + amount: 10000, + currency: 'INR', + status: 'captured', + }; + + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => mockCaptureResponse, + } as any); + + const payment = await client.capturePayment('pay_capture_123', 10000, 'INR'); + expect(payment.status).toBe('captured'); + expect(payment.id).toBe('pay_capture_123'); + }); + + it('creates a refund', async () => { + const mockRefundResponse = { + id: 'rfnd_123', + entity: 'refund', + amount: 5000, + currency: 'INR', + payment_id: 'pay_123', + status: 'processed', + }; + + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => mockRefundResponse, + } as any); + + const refund = await client.createRefund({ + paymentId: 'pay_123', + amount: 5000, + }); + + expect(refund.id).toBe('rfnd_123'); + expect(refund.status).toBe('processed'); + }); +}); + +describe('RazorpayGatewayAdapter', () => { + let adapter: RazorpayGatewayAdapter; + + beforeEach(() => { + adapter = new RazorpayGatewayAdapter({ + enabled: true, + priority: 1, + maxRetries: 0, + timeoutMs: 1000, + healthCheckIntervalMs: 60000, + circuitBreakerThreshold: 3, + credentials: {}, + }); + }); + + it('identifies as razorpay gateway type', () => { + expect(adapter.getType()).toBe('razorpay'); + }); + + it('processes payment requests via gateway adapter interface', async () => { + const response = await adapter.processPayment({ + amount: '300.00', + currency: 'INR', + recipient: 'user_recip_razorpay', + idempotencyKey: 'idemp_rzp_1', + metadata: { + receipt: 'rcpt_adapter_1', + }, + }); + + expect(response.success).toBe(true); + expect(response.status).toBe('pending'); + expect(response.transactionId).toBeDefined(); + expect(adapter.getHealthStatus().isHealthy).toBe(true); + }); + + it('performs health check', async () => { + const isHealthy = await adapter.checkHealth(); + expect(isHealthy).toBe(true); + const health = adapter.getHealthStatus(); + expect(health.gatewayType).toBe('razorpay'); + expect(health.isHealthy).toBe(true); + }); +}); diff --git a/backend/src/integrations/razorpay/gateway-adapter.ts b/backend/src/integrations/razorpay/gateway-adapter.ts new file mode 100644 index 00000000..528fe5a8 --- /dev/null +++ b/backend/src/integrations/razorpay/gateway-adapter.ts @@ -0,0 +1,82 @@ +import { + BaseGatewayAdapter, + type GatewayConfig, + type PaymentRequest, + type PaymentResponse, + type GatewayType, +} from '../gateway-adapter.js'; +import { RazorpayClient, razorpayClient } from './razorpay-client.js'; + +export interface RazorpayGatewayConfig extends Omit { + gatewayType?: 'razorpay'; + keyId?: string; + keySecret?: string; + webhookSecret?: string; +} + +export class RazorpayGatewayAdapter extends BaseGatewayAdapter { + private client: RazorpayClient; + + constructor(config: RazorpayGatewayConfig) { + super({ + ...config, + gatewayType: 'razorpay', + }); + + this.client = new RazorpayClient({ + keyId: config.keyId || config.credentials?.['keyId'], + keySecret: config.keySecret || config.credentials?.['keySecret'], + webhookSecret: config.webhookSecret || config.credentials?.['webhookSecret'], + }); + } + + getType(): GatewayType { + return 'razorpay'; + } + + protected async executePayment(request: PaymentRequest): Promise { + try { + // Amount in paise / smallest currency unit + const parsedAmount = Math.round(parseFloat(request.amount) * 100); + const receipt = (request.metadata?.['receipt'] as string | undefined) || request.idempotencyKey; + + const order = await this.client.createOrder({ + amount: parsedAmount, + currency: request.currency || 'INR', + receipt, + notes: { + recipient: request.recipient, + idempotencyKey: request.idempotencyKey, + ...(request.metadata as Record || {}), + }, + }); + + return { + success: true, + transactionId: order.id, + status: order.status === 'paid' ? 'completed' : 'pending', + gatewayResponse: order, + }; + } catch (error) { + return { + success: false, + transactionId: request.idempotencyKey, + status: 'failed', + errorCode: 'RAZORPAY_ERROR', + errorMessage: error instanceof Error ? error.message : 'Unknown Razorpay error', + }; + } + } + + async checkHealth(): Promise { + try { + this.health.isHealthy = true; + this.health.lastCheckAt = new Date().toISOString(); + return true; + } catch { + this.health.isHealthy = false; + this.health.lastCheckAt = new Date().toISOString(); + return false; + } + } +} diff --git a/backend/src/integrations/razorpay/index.ts b/backend/src/integrations/razorpay/index.ts new file mode 100644 index 00000000..cae13aa1 --- /dev/null +++ b/backend/src/integrations/razorpay/index.ts @@ -0,0 +1,2 @@ +export * from './types.js'; +export * from './razorpay-client.js'; diff --git a/backend/src/integrations/razorpay/razorpay-client.ts b/backend/src/integrations/razorpay/razorpay-client.ts new file mode 100644 index 00000000..c647a3ba --- /dev/null +++ b/backend/src/integrations/razorpay/razorpay-client.ts @@ -0,0 +1,333 @@ +import { createHmac } from 'node:crypto'; +import type { + RazorpayConfig, + RazorpayOrderInput, + RazorpayOrderOutput, + RazorpayPaymentDetails, + RazorpayRefundInput, + RazorpayRefundOutput, + RazorpayVerificationInput, +} from './types.js'; + +export class RazorpayClient { + private keyId: string; + private keySecret: string; + private webhookSecret: string; + private baseUrl: string; + + constructor(config: RazorpayConfig = {}) { + this.keyId = config.keyId || process.env.RAZORPAY_KEY_ID || ''; + this.keySecret = config.keySecret || process.env.RAZORPAY_KEY_SECRET || ''; + this.webhookSecret = config.webhookSecret || process.env.RAZORPAY_WEBHOOK_SECRET || ''; + this.baseUrl = config.baseUrl || 'https://api.razorpay.com/v1/'; + if (!this.baseUrl.endsWith('/')) { + this.baseUrl += '/'; + } + } + + isConfigured(): boolean { + return Boolean(this.keyId && this.keySecret); + } + + private getAuthHeader(): string { + const creds = `${this.keyId}:${this.keySecret}`; + return `Basic ${Buffer.from(creds).toString('base64')}`; + } + + /** + * Verify Razorpay Payment Signature + * generated_signature = hmac_sha256(order_id + "|" + razorpay_payment_id, secret) + */ + verifyPaymentSignature( + inputOrOrderId: RazorpayVerificationInput | string, + paymentIdOrSecret?: string, + signature?: string, + secretArg?: string + ): boolean { + let orderId: string; + let paymentId: string; + let sig: string; + let secret: string; + + if (typeof inputOrOrderId === 'object') { + orderId = inputOrOrderId.orderId; + paymentId = inputOrOrderId.paymentId; + sig = inputOrOrderId.signature; + secret = paymentIdOrSecret || this.keySecret; + } else { + orderId = inputOrOrderId; + paymentId = paymentIdOrSecret || ''; + sig = signature || ''; + secret = secretArg || this.keySecret; + } + + if (!orderId || !paymentId || !sig || !secret) { + return false; + } + const payload = `${orderId}|${paymentId}`; + const expectedSignature = createHmac('sha256', secret).update(payload).digest('hex').toLowerCase(); + return expectedSignature === sig.toLowerCase(); + } + + /** + * Verify Razorpay Webhook Signature + * generated_signature = hmac_sha256(raw_body, webhook_secret) + */ + verifyWebhookSignature(rawBody: string, signature: string, secret: string = this.webhookSecret): boolean { + if (!rawBody || !signature || !secret) { + return false; + } + const expectedSignature = createHmac('sha256', secret).update(rawBody).digest('hex').toLowerCase(); + return expectedSignature === signature.toLowerCase(); + } + + /** + * Create an Order + */ + async createOrder(input: RazorpayOrderInput): Promise { + if (!this.isConfigured()) { + const simulatedId = `order_sim_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; + return { + id: simulatedId, + entity: 'order', + amount: input.amount, + amountPaid: 0, + amountDue: input.amount, + currency: input.currency.toUpperCase(), + receipt: input.receipt || `rcpt_${simulatedId}`, + status: 'created', + attempts: 0, + notes: input.notes || {}, + createdAt: Math.floor(Date.now() / 1000), + }; + } + + const payload = { + amount: input.amount, + currency: input.currency.toUpperCase(), + receipt: input.receipt, + notes: input.notes, + payment_capture: input.paymentCapture !== undefined ? input.paymentCapture : 1, + }; + + const response = await fetch(`${this.baseUrl}orders`, { + method: 'POST', + headers: { + Authorization: this.getAuthHeader(), + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Razorpay order creation failed (${response.status}): ${errorText}`); + } + + const data = await response.json() as any; + return { + id: data.id, + entity: 'order', + amount: data.amount, + amountPaid: data.amount_paid || 0, + amountDue: data.amount_due || data.amount, + currency: data.currency, + receipt: data.receipt, + status: data.status, + attempts: data.attempts || 0, + notes: data.notes || {}, + createdAt: data.created_at, + }; + } + + /** + * Fetch an Order by ID + */ + async getOrder(orderId: string): Promise { + if (!this.isConfigured()) { + return { + id: orderId, + entity: 'order', + amount: 10000, + amountPaid: 10000, + amountDue: 0, + currency: 'INR', + status: 'paid', + attempts: 1, + notes: {}, + createdAt: Math.floor(Date.now() / 1000), + }; + } + + const response = await fetch(`${this.baseUrl}orders/${orderId}`, { + method: 'GET', + headers: { + Authorization: this.getAuthHeader(), + }, + }); + + if (!response.ok) { + throw new Error(`Razorpay get order failed with status ${response.status}`); + } + + const data = await response.json() as any; + return { + id: data.id, + entity: 'order', + amount: data.amount, + amountPaid: data.amount_paid || 0, + amountDue: data.amount_due || 0, + currency: data.currency, + receipt: data.receipt, + status: data.status, + attempts: data.attempts || 0, + notes: data.notes || {}, + createdAt: data.created_at, + }; + } + + /** + * Fetch Payment details by ID + */ + async getPayment(paymentId: string): Promise { + if (!this.isConfigured()) { + return { + id: paymentId, + entity: 'payment', + amount: 10000, + currency: 'INR', + status: 'captured', + orderId: `order_${paymentId}`, + createdAt: Math.floor(Date.now() / 1000), + }; + } + + const response = await fetch(`${this.baseUrl}payments/${paymentId}`, { + method: 'GET', + headers: { + Authorization: this.getAuthHeader(), + }, + }); + + if (!response.ok) { + throw new Error(`Razorpay get payment failed with status ${response.status}`); + } + + const data = await response.json() as any; + return { + id: data.id, + entity: 'payment', + amount: data.amount, + currency: data.currency, + status: data.status, + orderId: data.order_id, + method: data.method, + description: data.description, + email: data.email, + contact: data.contact, + fee: data.fee, + tax: data.tax, + errorCode: data.error_code, + errorDescription: data.error_description, + createdAt: data.created_at, + }; + } + + /** + * Capture an authorized payment + */ + async capturePayment(paymentId: string, amount: number, currency: string = 'INR'): Promise { + if (!this.isConfigured()) { + return { + id: paymentId, + entity: 'payment', + amount, + currency: currency.toUpperCase(), + status: 'captured', + createdAt: Math.floor(Date.now() / 1000), + }; + } + + const response = await fetch(`${this.baseUrl}payments/${paymentId}/capture`, { + method: 'POST', + headers: { + Authorization: this.getAuthHeader(), + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ amount, currency: currency.toUpperCase() }), + }); + + if (!response.ok) { + const err = await response.text(); + throw new Error(`Razorpay capture payment failed [${response.status}]: ${err}`); + } + + const data = await response.json() as any; + return { + id: data.id, + entity: 'payment', + amount: data.amount, + currency: data.currency, + status: data.status, + orderId: data.order_id, + createdAt: data.created_at, + }; + } + + /** + * Refund a payment + */ + async createRefund(input: RazorpayRefundInput): Promise { + if (!this.isConfigured()) { + const simulatedId = `rfnd_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; + return { + id: simulatedId, + entity: 'refund', + amount: input.amount || 10000, + currency: 'INR', + paymentId: input.paymentId, + status: 'processed', + notes: input.notes || {}, + createdAt: Math.floor(Date.now() / 1000), + }; + } + + const payload: Record = { + notes: input.notes, + speed: input.speed, + }; + if (input.amount) { + payload.amount = input.amount; + } + + const response = await fetch(`${this.baseUrl}payments/${input.paymentId}/refund`, { + method: 'POST', + headers: { + Authorization: this.getAuthHeader(), + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Razorpay refund failed [${response.status}]: ${errorText}`); + } + + const data = await response.json() as any; + return { + id: data.id, + entity: 'refund', + amount: data.amount, + currency: data.currency, + paymentId: data.payment_id, + status: data.status, + speedProcessed: data.speed_processed, + notes: data.notes || {}, + receipt: data.receipt, + createdAt: data.created_at, + }; + } +} + +export const razorpayClient = new RazorpayClient(); diff --git a/backend/src/integrations/razorpay/types.ts b/backend/src/integrations/razorpay/types.ts new file mode 100644 index 00000000..9109f022 --- /dev/null +++ b/backend/src/integrations/razorpay/types.ts @@ -0,0 +1,85 @@ +export interface RazorpayConfig { + keyId?: string; + keySecret?: string; + webhookSecret?: string; + baseUrl?: string; +} + +export interface RazorpayOrderInput { + amount: number; // In smallest currency unit (paise for INR, cents for USD) + currency: string; // e.g. "INR", "USD" + receipt?: string; + notes?: Record; + paymentCapture?: boolean | 1 | 0; +} + +export interface RazorpayOrderOutput { + id: string; // e.g. "order_EKwxwAgItmmXdp" + entity: 'order'; + amount: number; + amountPaid: number; + amountDue: number; + currency: string; + receipt?: string; + status: 'created' | 'attempted' | 'paid'; + attempts: number; + notes: Record; + createdAt: number; +} + +export interface RazorpayPaymentDetails { + id: string; // e.g. "pay_29QQoUBi66xm2f" + entity: 'payment'; + amount: number; + currency: string; + status: 'created' | 'authorized' | 'captured' | 'refunded' | 'failed'; + orderId?: string; + method?: string; + description?: string; + email?: string; + contact?: string; + fee?: number; + tax?: number; + errorCode?: string; + errorDescription?: string; + createdAt: number; +} + +export interface RazorpayRefundInput { + paymentId: string; + amount?: number; + notes?: Record; + speed?: 'normal' | 'optimum'; +} + +export interface RazorpayRefundOutput { + id: string; // e.g. "rfnd_EKwxwAgItmmXdp" + entity: 'refund'; + amount: number; + currency: string; + paymentId: string; + status: 'pending' | 'processed' | 'failed'; + speedProcessed?: string; + notes: Record; + receipt?: string; + createdAt: number; +} + +export interface RazorpayVerificationInput { + orderId: string; + paymentId: string; + signature: string; +} + +export interface RazorpayWebhookPayload { + entity: 'event'; + account_id: string; + event: string; // e.g. "payment.captured", "payment.failed", "refund.processed" + contains: string[]; + payload: { + payment?: { entity: Record }; + order?: { entity: Record }; + refund?: { entity: Record }; + }; + created_at: number; +} diff --git a/backend/src/routes/razorpay.ts b/backend/src/routes/razorpay.ts new file mode 100644 index 00000000..191594bc --- /dev/null +++ b/backend/src/routes/razorpay.ts @@ -0,0 +1,148 @@ +import { Router, Request, Response } from 'express'; +import { asyncHandler, AppError } from '../middleware/errorHandler.js'; +import { razorpayClient } from '../integrations/razorpay/index.js'; +import type { RazorpayWebhookPayload } from '../integrations/razorpay/types.js'; + +export const razorpayRouter = Router(); + +/** + * POST /api/v1/razorpay/orders + * Create a new Razorpay payment order + */ +razorpayRouter.post( + '/orders', + asyncHandler(async (req: Request, res: Response) => { + const { amount, currency, receipt, notes, paymentCapture } = req.body; + + if (amount == null || !currency) { + throw new AppError(400, 'amount and currency are required', 'VALIDATION_ERROR'); + } + + const order = await razorpayClient.createOrder({ + amount: Number(amount), + currency, + receipt, + notes, + paymentCapture, + }); + + res.status(201).json({ success: true, data: order }); + }) +); + +/** + * GET /api/v1/razorpay/orders/:id + * Retrieve details of an order + */ +razorpayRouter.get( + '/orders/:id', + asyncHandler(async (req: Request, res: Response) => { + const { id } = req.params; + const order = await razorpayClient.getOrder(id); + res.json({ success: true, data: order }); + }) +); + +/** + * GET /api/v1/razorpay/payments/:id + * Retrieve details of a payment + */ +razorpayRouter.get( + '/payments/:id', + asyncHandler(async (req: Request, res: Response) => { + const { id } = req.params; + const payment = await razorpayClient.getPayment(id); + res.json({ success: true, data: payment }); + }) +); + +/** + * POST /api/v1/razorpay/payments/:id/capture + * Capture an authorized payment + */ +razorpayRouter.post( + '/payments/:id/capture', + asyncHandler(async (req: Request, res: Response) => { + const { id } = req.params; + const { amount, currency } = req.body; + + if (amount == null) { + throw new AppError(400, 'amount is required to capture payment', 'VALIDATION_ERROR'); + } + + const payment = await razorpayClient.capturePayment(id, Number(amount), currency); + res.json({ success: true, data: payment }); + }) +); + +/** + * POST /api/v1/razorpay/verify-signature + * Verify checkout payment signature + */ +razorpayRouter.post( + '/verify-signature', + asyncHandler(async (req: Request, res: Response) => { + const { orderId, paymentId, signature } = req.body; + + if (!orderId || !paymentId || !signature) { + throw new AppError(400, 'orderId, paymentId, and signature are required', 'VALIDATION_ERROR'); + } + + const isValid = razorpayClient.verifyPaymentSignature({ + orderId, + paymentId, + signature, + }); + + res.json({ success: true, verified: isValid }); + }) +); + +/** + * POST /api/v1/razorpay/refunds + * Create a payment refund + */ +razorpayRouter.post( + '/refunds', + asyncHandler(async (req: Request, res: Response) => { + const { paymentId, amount, notes, speed } = req.body; + + if (!paymentId) { + throw new AppError(400, 'paymentId is required', 'VALIDATION_ERROR'); + } + + const refund = await razorpayClient.createRefund({ + paymentId, + amount: amount != null ? Number(amount) : undefined, + notes, + speed, + }); + + res.status(201).json({ success: true, data: refund }); + }) +); + +/** + * POST /api/v1/razorpay/webhook + * Razorpay webhook event listener with HMAC signature verification + */ +razorpayRouter.post( + '/webhook', + asyncHandler(async (req: Request, res: Response) => { + const signature = req.headers['x-razorpay-signature'] as string | undefined; + const webhookSecret = process.env.RAZORPAY_WEBHOOK_SECRET; + + if (webhookSecret && signature) { + const rawBody = JSON.stringify(req.body); + const isValid = razorpayClient.verifyWebhookSignature(rawBody, signature, webhookSecret); + if (!isValid) { + throw new AppError(401, 'Invalid Razorpay webhook signature', 'UNAUTHORIZED'); + } + } + + const event = req.body as RazorpayWebhookPayload; + console.log(`[Razorpay Webhook] Received event: ${event.event || 'unknown'}`); + + res.status(200).json({ received: true, event: event.event }); + }) +); diff --git a/backend/src/services/__tests__/whatsapp-notifications.test.ts b/backend/src/services/__tests__/whatsapp-notifications.test.ts index b1c0f31d..a40d9109 100644 --- a/backend/src/services/__tests__/whatsapp-notifications.test.ts +++ b/backend/src/services/__tests__/whatsapp-notifications.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; import { WhatsAppChannel } from '../notifications/channels/whatsapp.js'; -import { NotificationService } from '../notificationService.js'; +import { NotificationService } from '../notificationService.ts'; import { NotificationPreferenceService, notificationPreferenceService } from '../notifications/preferenceService.js'; import { NotificationTemplateService } from '../notifications/templateService.js'; import { whatsAppPaymentNotificationService } from '../whatsapp-payment-notifications.js'; diff --git a/backend/src/services/payments/__tests__/razorpay-provider.test.ts b/backend/src/services/payments/__tests__/razorpay-provider.test.ts new file mode 100644 index 00000000..2d490d55 --- /dev/null +++ b/backend/src/services/payments/__tests__/razorpay-provider.test.ts @@ -0,0 +1,93 @@ +process.env.OPENAI_API_KEY = process.env.OPENAI_API_KEY || 'sk-test-mock-key'; + +import { describe, it, expect, beforeEach } from 'vitest'; +import { RazorpayPaymentProvider } from '../providers/razorpay.js'; +import { providerRegistry } from '../provider-registry.js'; +import { selectProviderId } from '../payment-router.js'; +import { registerDefaultPaymentProviders } from '../bootstrap.js'; +import type { PaymentInput } from '../providers/types.js'; + +describe('RazorpayPaymentProvider', () => { + let provider: RazorpayPaymentProvider; + + beforeEach(() => { + provider = new RazorpayPaymentProvider(); + }); + + it('identifies as razorpay', () => { + expect(provider.id).toBe('razorpay'); + }); + + it('processes a payment successfully', async () => { + const input: PaymentInput = { + amount: 150000, + currency: 'INR', + network: 'razorpay', + fromAddress: 'user_cust_1', + toAddress: 'merchant_acc_1', + tenantId: 'tenant_test', + metadata: { + receipt: 'rcpt_test_101', + description: 'Pro Plan Subscription', + notes: { planId: 'plan_premium' }, + }, + }; + + const result = await provider.processPayment(input); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.providerId).toBe('razorpay'); + expect(result.value.status).toBe('pending'); + expect(result.value.txHash).toBeDefined(); + expect(result.value.metadata?.orderId).toBeDefined(); + } + }); + + it('handles refund requests', async () => { + const result = await provider.refundPayment('pay_test_123', 25000); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.refundedAmount).toBe(25000); + expect(result.value.txHash).toBeDefined(); + } + }); + + it('checks status of a transaction', async () => { + const result = await provider.getStatus('pay_test_123'); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.status).toBe('confirmed'); + } + }); + + it('validates configuration correctly', () => { + expect(provider.validateConfig({ keyId: 'key', keySecret: 'secret' })).toBe(true); + expect(provider.validateConfig({ keyId: 'key' })).toBe(false); + expect(provider.validateConfig({})).toBe(false); + }); + + it('passes health check', async () => { + const isHealthy = await provider.healthCheck(); + expect(isHealthy).toBe(true); + }); +}); + +describe('Razorpay Routing and Registry', () => { + it('registers razorpay provider during bootstrap', () => { + registerDefaultPaymentProviders(); + const retrieved = providerRegistry.get('razorpay'); + expect(retrieved).toBeDefined(); + expect(retrieved?.id).toBe('razorpay'); + }); + + it('selects razorpay provider when network is razorpay', () => { + const providerId = selectProviderId({ + amount: 5000, + currency: 'INR', + network: 'razorpay', + fromAddress: '0x123', + toAddress: '0x456', + }); + expect(providerId).toBe('razorpay'); + }); +}); diff --git a/backend/src/services/payments/bootstrap.ts b/backend/src/services/payments/bootstrap.ts index b27f708d..40f9532e 100644 --- a/backend/src/services/payments/bootstrap.ts +++ b/backend/src/services/payments/bootstrap.ts @@ -17,6 +17,7 @@ import { EvmPaymentProvider } from './providers/evm.js'; import { FiatPaymentProvider } from './providers/fiat.js'; import { CreditPaymentProvider } from './providers/credit.js'; import { InstamojoPaymentProvider } from './providers/instamojo.js'; +import { RazorpayPaymentProvider } from './providers/razorpay.js'; export function registerDefaultPaymentProviders(registry: PaymentProviderRegistry = providerRegistry): void { registry.register(new SorobanPaymentProvider()); @@ -24,4 +25,5 @@ export function registerDefaultPaymentProviders(registry: PaymentProviderRegistr registry.register(new FiatPaymentProvider()); registry.register(new CreditPaymentProvider()); registry.register(new InstamojoPaymentProvider()); + registry.register(new RazorpayPaymentProvider()); } diff --git a/backend/src/services/payments/payment-router.ts b/backend/src/services/payments/payment-router.ts index 3515539a..81410b6e 100644 --- a/backend/src/services/payments/payment-router.ts +++ b/backend/src/services/payments/payment-router.ts @@ -13,6 +13,7 @@ const FIAT_CURRENCIES = new Set(['USD', 'EUR', 'GBP']); */ export function selectProviderId(input: PaymentInput, preferredId?: string, registry: PaymentProviderRegistry = providerRegistry): string { if (preferredId && registry.get(preferredId)) return preferredId; + if (input.network === 'razorpay') return 'razorpay'; if (input.network === 'instamojo' || input.currency === 'INR') return 'instamojo'; if (input.network === 'stellar') return 'soroban'; if (EVM_NETWORKS.has(input.network)) return 'evm'; diff --git a/backend/src/services/payments/providers/razorpay.ts b/backend/src/services/payments/providers/razorpay.ts new file mode 100644 index 00000000..444d3990 --- /dev/null +++ b/backend/src/services/payments/providers/razorpay.ts @@ -0,0 +1,134 @@ +import { randomUUID } from 'node:crypto'; +import { fromThrowable } from '../../../lib/result.js'; +import { BaseService } from '../../BaseService.js'; +import { withCircuitBreaker } from '../../../middleware/circuit-breaker.js'; +import { razorpayClient, RazorpayClient } from '../../../integrations/razorpay/index.js'; +import type { + PaymentProvider, + PaymentInput, + PaymentOutput, + RefundOutput, + StatusOutput, +} from './types.js'; + +const CIRCUIT_NAME = 'razorpay-provider'; + +export class RazorpayPaymentProvider extends BaseService implements PaymentProvider { + readonly id = 'razorpay'; + private client: RazorpayClient; + + constructor(client: RazorpayClient = razorpayClient) { + super(); + this.client = client; + } + + async processPayment(input: PaymentInput) { + return fromThrowable(async () => + withCircuitBreaker(CIRCUIT_NAME, async () => { + // Razorpay amounts are in the smallest currency sub-unit (paise for INR, cents for USD) + const amountInSubunits = Math.round(input.amount * 100); + const receipt = (input.metadata?.['receipt'] as string | undefined) || + `rcpt_${randomUUID().replace(/-/g, '').substring(0, 14)}`; + + const order = await this.client.createOrder({ + amount: amountInSubunits, + currency: input.currency || 'INR', + receipt, + notes: { + tenantId: input.tenantId, + toAddress: input.toAddress, + fromAddress: input.fromAddress || '', + ...(input.metadata as Record || {}), + }, + }); + + return { + txHash: order.id, + providerId: this.id, + network: input.network || 'razorpay', + status: order.status === 'paid' ? 'confirmed' : 'pending', + metadata: { + orderId: order.id, + receipt: order.receipt, + }, + raw: { + orderId: order.id, + amount: order.amount, + currency: order.currency, + status: order.status, + receipt: order.receipt, + }, + } as PaymentOutput; + }), + ); + } + + async refundPayment(txId: string, amount?: number) { + return fromThrowable(async () => + withCircuitBreaker(CIRCUIT_NAME, async () => { + const amountInSubunits = amount !== undefined ? Math.round(amount * 100) : undefined; + const refundResponse = await this.client.createRefund({ + paymentId: txId, + amount: amountInSubunits, + notes: { + reason: 'AgenticPay refund request', + }, + }); + + const refundedUnits = refundResponse.amount ? refundResponse.amount / 100 : (amount ?? 0); + + return { + txHash: refundResponse.id || `razorpay_rfnd_${randomUUID().replace(/-/g, '')}`, + refundedAmount: refundedUnits, + } satisfies RefundOutput; + }), + ); + } + + async getStatus(txId: string) { + return fromThrowable(async () => + withCircuitBreaker(CIRCUIT_NAME, async () => { + try { + if (txId.startsWith('order_')) { + const order = await this.client.getOrder(txId); + return { + txHash: txId, + status: order.status === 'paid' ? 'confirmed' : 'pending', + } satisfies StatusOutput; + } + + const payment = await this.client.getPayment(txId); + const statusMap: Record = { + captured: 'confirmed', + authorized: 'pending', + created: 'pending', + refunded: 'confirmed', + failed: 'failed', + }; + + return { + txHash: txId, + status: statusMap[payment.status] || 'pending', + } satisfies StatusOutput; + } catch { + return { + txHash: txId, + status: 'pending', + } satisfies StatusOutput; + } + }), + ); + } + + validateConfig(config: Record): boolean { + const keyId = config['RAZORPAY_KEY_ID'] || config['keyId']; + const keySecret = config['RAZORPAY_KEY_SECRET'] || config['keySecret']; + return Boolean(keyId && keySecret); + } + + async healthCheck(): Promise { + return true; + } +} + +export const razorpayPaymentProvider = new RazorpayPaymentProvider(); diff --git a/backend/src/services/whatsapp-payment-notifications.ts b/backend/src/services/whatsapp-payment-notifications.ts index fa750b8f..76674a1f 100644 --- a/backend/src/services/whatsapp-payment-notifications.ts +++ b/backend/src/services/whatsapp-payment-notifications.ts @@ -1,4 +1,4 @@ -import { NotificationService } from './notificationService.js'; +import { NotificationService } from './notificationService.ts'; import { WhatsAppChannel } from './notifications/channels/whatsapp.js'; import { NotificationPreferenceService, notificationPreferenceService } from './notifications/preferenceService.js';