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
6 changes: 2 additions & 4 deletions backend/src/config/env.js
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
2 changes: 2 additions & 0 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion backend/src/integrations/gateway-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
205 changes: 205 additions & 0 deletions backend/src/integrations/razorpay/__tests__/razorpay.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
82 changes: 82 additions & 0 deletions backend/src/integrations/razorpay/gateway-adapter.ts
Original file line number Diff line number Diff line change
@@ -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<GatewayConfig, 'gatewayType'> {
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<PaymentResponse> {
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<string, string> || {}),
},
});

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<boolean> {
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;
}
}
}
2 changes: 2 additions & 0 deletions backend/src/integrations/razorpay/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './types.js';
export * from './razorpay-client.js';
Loading
Loading