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
12 changes: 11 additions & 1 deletion backend/src/config/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -601,7 +601,17 @@ export {
type ReplicaSnapshot,
} from '../db/read-replica-router.js';

export const readReplicaRouter = getReadReplicaRouter();
export const getReadReplicaRouterInstance = () => getReadReplicaRouter();
export const readReplicaRouter = {
get instance() {
return getReadReplicaRouter();
},
selectReadUrl: (...args: Parameters<ReadReplicaRouter['selectReadUrl']>) => getReadReplicaRouter().selectReadUrl(...args),
recordFailure: (...args: Parameters<ReadReplicaRouter['recordFailure']>) => getReadReplicaRouter().recordFailure(...args),
recordSuccess: (...args: Parameters<ReadReplicaRouter['recordSuccess']>) => getReadReplicaRouter().recordSuccess(...args),
checkHealth: (...args: Parameters<ReadReplicaRouter['checkHealth']>) => getReadReplicaRouter().checkHealth(...args),
getSnapshot: (...args: Parameters<ReadReplicaRouter['getSnapshot']>) => getReadReplicaRouter().getSnapshot(...args),
};

export interface ReplicaConfig {
host: string;
Expand Down
4 changes: 1 addition & 3 deletions backend/src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,7 @@ const envSchema = z.object({
PORT: z.coerce.number().default(3001),
CORS_ALLOWED_ORIGINS: z.string().default('*'),
STELLAR_NETWORK: z.enum(['testnet', 'public']).default('testnet'),
OPENAI_API_KEY: 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: z.string().default('mock-openai-key'),
JOBS_ENABLED: z.coerce.string().transform((val) => val !== 'false').default('true'),
QUEUE_ENABLED: z.coerce.string().transform((val) => val !== 'false').default('true'),
RATE_LIMIT_FREE: 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 @@ -54,6 +54,7 @@ import { ipAllowlistMiddleware, initIpAllowlist } from './middleware/ip-allowlis
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 { auditRouter } from './routes/audit.js';
import { hedgingRouter } from './routes/hedging.js';
import { complianceRouter } from './routes/compliance.js';
Expand Down Expand Up @@ -416,6 +417,7 @@ apiV1Router.get('/pool/metrics', (_req, res) => {
app.use('/api/v1', ipAllowlistMiddleware(), apiV1Router);

app.use('/api/v1/notifications', notificationsRouter);
app.use('/api/v1/instamojo', instamojoRouter);
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';
export type GatewayType = 'stellar' | 'stripe' | 'paypal' | 'coinbase' | 'instamojo';

/**
* Payment request
Expand Down
194 changes: 194 additions & 0 deletions backend/src/integrations/instamojo/__tests__/instamojo.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { InstamojoClient } from '../instamojo-client.js';
import { InstamojoGatewayAdapter } from '../gateway-adapter.js';
import type { InstamojoWebhookPayload } from '../types.js';

describe('InstamojoClient', () => {
let client: InstamojoClient;
const originalFetch = global.fetch;

beforeEach(() => {
client = new InstamojoClient({
apiKey: 'test_api_key',
authToken: 'test_auth_token',
privateSalt: 'secret_salt_123',
endpointUrl: 'https://test.instamojo.com/api/1.1/',
});
});

afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});

it('correctly calculates and validates webhook MAC signature', () => {
const salt = 'test_salt_xyz';
const payload: InstamojoWebhookPayload = {
payment_id: 'MOJO12345678',
payment_request_id: 'req_abc123',
status: 'Credit',
amount: '500.00',
buyer: 'buyer@example.com',
buyer_name: 'Test Buyer',
buyer_phone: '+919999999999',
currency: 'INR',
fees: '15.00',
};

// Calculate expected MAC
const sortedKeys = Object.keys(payload).sort((a, b) => a.localeCompare(b));
const message = sortedKeys.map((k) => String(payload[k])).join('|');
const { createHmac } = require('node:crypto');
const expectedMac = createHmac('sha1', salt).update(message).digest('hex');

payload.mac = expectedMac;

const isValid = client.verifyWebhookMac(payload, salt);
expect(isValid).toBe(true);
});

it('rejects invalid webhook MAC signature', () => {
const payload: InstamojoWebhookPayload = {
payment_id: 'MOJO12345678',
payment_request_id: 'req_abc123',
status: 'Credit',
amount: '500.00',
mac: 'invalid_mac_signature',
};

const isValid = client.verifyWebhookMac(payload, 'test_salt_xyz');
expect(isValid).toBe(false);
});

it('creates payment request successfully with API call', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
success: true,
payment_request: {
id: 'pr_real_123',
phone: '+919876543210',
email: 'customer@example.com',
buyer_name: 'Jane Doe',
amount: '250.00',
purpose: 'Order 99',
status: 'Pending',
longurl: 'https://test.instamojo.com/@agenticpay/pr_real_123',
created_at: '2026-10-21T09:00:00Z',
},
}),
} as unknown as Response);

const result = await client.createPaymentRequest({
purpose: 'Order 99',
amount: 250,
buyerName: 'Jane Doe',
email: 'customer@example.com',
phone: '+919876543210',
});

expect(result.id).toBe('pr_real_123');
expect(result.longurl).toContain('pr_real_123');
expect(global.fetch).toHaveBeenCalledTimes(1);

const [url, opts] = (global.fetch as any).mock.calls[0];
expect(url).toBe('https://test.instamojo.com/api/1.1/payment-requests/');
expect(opts.headers['X-Api-Key']).toBe('test_api_key');
expect(opts.headers['X-Auth-Token']).toBe('test_auth_token');
});

it('throws error when remote API returns failure status', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 400,
text: async () => '{"amount":["This field is required."]}',
} as unknown as Response);

await expect(
client.createPaymentRequest({
purpose: 'Failing payment',
amount: 0,
})
).rejects.toThrow('Instamojo create payment request failed [400]');
});

it('handles simulated payment requests when credentials are empty', async () => {
const unconfigured = new InstamojoClient({ apiKey: '', authToken: '' });
const result = await unconfigured.createPaymentRequest({
purpose: 'Dev Test',
amount: 100,
buyerName: 'Dev User',
});

expect(result.id).toMatch(/^mojo_req_/);
expect(result.longurl).toContain('mojo_req_');
expect(result.status).toBe('Pending');
});

it('creates refunds via remote API', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
success: true,
refund: {
id: 'rfnd_123',
payment_id: 'MOJO_pay_1',
status: 'Refunded',
type: 'RFD',
body: 'Customer canceled',
refund_amount: '250.00',
total_amount: '250.00',
created_at: '2026-10-21T09:10:00Z',
},
}),
} as unknown as Response);

const refund = await client.createRefund({
paymentId: 'MOJO_pay_1',
type: 'RFD',
body: 'Customer canceled',
refundAmount: 250,
});

expect(refund.id).toBe('rfnd_123');
expect(refund.status).toBe('Refunded');
expect(refund.refundAmount).toBe('250.00');
});
});

describe('InstamojoGatewayAdapter', () => {
let adapter: InstamojoGatewayAdapter;

beforeEach(() => {
adapter = new InstamojoGatewayAdapter({
enabled: true,
priority: 1,
maxRetries: 0,
timeoutMs: 1000,
healthCheckIntervalMs: 60000,
circuitBreakerThreshold: 3,
credentials: {},
});
});

it('identifies as instamojo gateway type', () => {
expect(adapter.getType()).toBe('instamojo');
});

it('processes payment requests via gateway adapter interface', async () => {
const response = await adapter.processPayment({
amount: '50.00',
currency: 'INR',
recipient: 'user_recip',
idempotencyKey: 'idemp_mojo_1',
metadata: {
purpose: 'Test gateway',
},
});

expect(response.success).toBe(true);
expect(response.status).toBe('pending');
expect(response.transactionId).toBeDefined();
expect(adapter.getHealthStatus().isHealthy).toBe(true);
});
});
90 changes: 90 additions & 0 deletions backend/src/integrations/instamojo/gateway-adapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import {
BaseGatewayAdapter,
type GatewayConfig,
type PaymentRequest,
type PaymentResponse,
type GatewayType,
} from '../gateway-adapter.js';
import { InstamojoClient, instamojoClient } from './instamojo-client.js';

export interface InstamojoGatewayConfig extends Omit<GatewayConfig, 'gatewayType'> {
gatewayType?: 'instamojo';
apiKey?: string;
authToken?: string;
privateSalt?: string;
endpointUrl?: string;
sandbox?: boolean;
}

export class InstamojoGatewayAdapter extends BaseGatewayAdapter {
private client: InstamojoClient;

constructor(config: InstamojoGatewayConfig) {
super({
...config,
gatewayType: 'instamojo',
});

this.client = new InstamojoClient({
apiKey: config.apiKey || config.credentials?.['apiKey'],
authToken: config.authToken || config.credentials?.['authToken'],
privateSalt: config.privateSalt || config.credentials?.['privateSalt'],
endpointUrl: config.endpointUrl || config.credentials?.['endpointUrl'],
sandbox: config.sandbox,
});
}

getType(): GatewayType {
return 'instamojo';
}

protected async executePayment(request: PaymentRequest): Promise<PaymentResponse> {
try {
const purpose = (request.metadata?.['purpose'] as string | undefined) ||
`Payment ${request.idempotencyKey}`;
const buyerName = (request.metadata?.['buyerName'] as string | undefined) ||
(request.metadata?.['customerName'] as string | undefined) ||
'AgenticPay Customer';
const email = (request.metadata?.['email'] as string | undefined) ||
`${request.recipient}@example.com`;
const phone = (request.metadata?.['phone'] as string | undefined) || '+919999999999';

const result = await this.client.createPaymentRequest({
purpose,
amount: request.amount,
buyerName,
email,
phone,
redirectUrl: request.metadata?.['redirectUrl'] as string | undefined,
webhookUrl: request.metadata?.['webhookUrl'] as string | undefined,
});

return {
success: true,
transactionId: result.id,
status: result.status === 'Completed' ? 'completed' : 'pending',
gatewayResponse: result,
};
} catch (error) {
return {
success: false,
transactionId: request.idempotencyKey,
status: 'failed',
errorCode: 'INSTAMOJO_ERROR',
errorMessage: error instanceof Error ? error.message : 'Unknown Instamojo 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/instamojo/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './types.js';
export * from './instamojo-client.js';
Loading
Loading