Files
insforge--insforge/backend/tests/unit/razorpay-webhook.service.test.ts
wehub-resource-sync 3a28426bf4
Lint and Format Check / lint-and-format (push) Failing after 0s
Check Migrations / Check for duplicate migration numbers (push) Failing after 1s
CI Pre-merge Check / CI Pre-merge Check (push) Failing after 2m17s
chore: import upstream snapshot with attribution
2026-07-13 12:23:40 +08:00

916 lines
27 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { RazorpayWebhookPayload } from '../../src/providers/payments/razorpay.provider';
import type { RazorpayWebhookEventRow } from '../../src/services/payments/razorpay/webhook.service';
import type { RazorpayEnvironment } from '../../src/types/payments';
const { mockConfigService, mockProvider, mockPool } = vi.hoisted(() => ({
mockConfigService: {
getRazorpayWebhookSecret: vi.fn(),
createRazorpayProvider: vi.fn(),
},
mockProvider: {
verifyWebhookSignature: vi.fn(),
},
mockPool: {
query: vi.fn(),
},
}));
vi.mock('../../src/infra/database/database.manager', () => ({
DatabaseManager: {
getInstance: () => ({
getPool: () => mockPool,
}),
},
}));
vi.mock('../../src/services/payments/razorpay/config.service', () => ({
RazorpayConfigService: {
getInstance: () => mockConfigService,
},
}));
import { RazorpayWebhookService } from '../../src/services/payments/razorpay/webhook.service';
interface RazorpayWebhookServiceInternals {
applyRazorpayWebhookEvent(
environment: RazorpayEnvironment,
payload: RazorpayWebhookPayload
): Promise<boolean>;
}
function getServiceInternals(service: RazorpayWebhookService): RazorpayWebhookServiceInternals {
return service as unknown as RazorpayWebhookServiceInternals;
}
function makeWebhookRow(overrides: Partial<RazorpayWebhookEventRow> = {}): RazorpayWebhookEventRow {
return {
id: 'evt_row_123',
environment: 'test',
eventId: 'evt_123',
eventType: 'payment.captured',
processingStatus: 'pending',
attemptCount: 1,
lastError: null,
receivedAt: '2026-06-05T00:00:00.000Z',
processedAt: null,
...overrides,
};
}
function makeRawWebhookBody(event: string): Buffer {
return Buffer.from(
JSON.stringify({
entity: 'event',
account_id: 'acc_123',
event,
contains: ['payment'],
payload: {
payment: {
entity: {
id: 'pay_123',
},
},
},
created_at: 1780617600,
})
);
}
function makeWebhookPayload(event: string): RazorpayWebhookPayload {
return JSON.parse(makeRawWebhookBody(event).toString('utf8')) as RazorpayWebhookPayload;
}
describe('RazorpayWebhookService', () => {
beforeEach(() => {
vi.restoreAllMocks();
vi.clearAllMocks();
mockConfigService.getRazorpayWebhookSecret.mockResolvedValue('whsec_123');
mockConfigService.createRazorpayProvider.mockResolvedValue(mockProvider);
mockProvider.verifyWebhookSignature.mockReturnValue(true);
mockPool.query.mockResolvedValue({ rows: [], rowCount: 0 });
});
it('processes handled Razorpay events before acknowledging them', async () => {
const service = RazorpayWebhookService.getInstance();
const recordSpy = vi
.spyOn(service, 'recordWebhookEventStart')
.mockResolvedValue({ shouldProcess: true, row: makeWebhookRow() });
const markSpy = vi.spyOn(service, 'markWebhookEvent').mockResolvedValue(makeWebhookRow());
const applySpy = vi
.spyOn(getServiceInternals(service), 'applyRazorpayWebhookEvent')
.mockResolvedValue(true);
const result = await service.handleRazorpayWebhook(
'test',
makeRawWebhookBody('payment.captured'),
'signature',
'evt_header_123'
);
expect(result).toEqual({ received: true, handled: true });
expect(mockProvider.verifyWebhookSignature).toHaveBeenCalledWith(
expect.any(Buffer),
'signature',
'whsec_123'
);
expect(recordSpy).toHaveBeenCalledWith(
'test',
'evt_header_123',
'payment.captured',
expect.objectContaining({ event: 'payment.captured' })
);
expect(applySpy).toHaveBeenCalledWith(
'test',
expect.objectContaining({ event: 'payment.captured' })
);
expect(markSpy).toHaveBeenCalledWith('test', 'evt_header_123', 'processed', null);
});
it('marks unhandled Razorpay events ignored without syncing', async () => {
const service = RazorpayWebhookService.getInstance();
vi.spyOn(service, 'recordWebhookEventStart').mockResolvedValue({
shouldProcess: true,
row: makeWebhookRow({ eventType: 'customer.created' }),
});
const markSpy = vi.spyOn(service, 'markWebhookEvent').mockResolvedValue(makeWebhookRow());
const applySpy = vi
.spyOn(getServiceInternals(service), 'applyRazorpayWebhookEvent')
.mockResolvedValue(true);
const result = await service.handleRazorpayWebhook(
'test',
makeRawWebhookBody('customer.created'),
'signature',
'evt_header_456'
);
expect(result).toEqual({ received: true, handled: false });
expect(markSpy).toHaveBeenCalledWith('test', 'evt_header_456', 'ignored', null);
expect(applySpy).not.toHaveBeenCalled();
});
it('marks handled Razorpay events failed and rethrows processing failures', async () => {
const service = RazorpayWebhookService.getInstance();
vi.spyOn(service, 'recordWebhookEventStart').mockResolvedValue({
shouldProcess: true,
row: makeWebhookRow(),
});
const markSpy = vi.spyOn(service, 'markWebhookEvent').mockResolvedValue(makeWebhookRow());
vi.spyOn(getServiceInternals(service), 'applyRazorpayWebhookEvent').mockRejectedValue(
new Error('handler failed')
);
await expect(
service.handleRazorpayWebhook(
'test',
makeRawWebhookBody('payment.captured'),
'signature',
'evt_header_789'
)
).rejects.toThrow('handler failed');
expect(markSpy).toHaveBeenCalledWith('test', 'evt_header_789', 'failed', 'handler failed');
});
it('records new Razorpay webhook events for processing', async () => {
const service = RazorpayWebhookService.getInstance();
mockPool.query.mockResolvedValueOnce({
rows: [makeWebhookRow({ eventId: 'evt_new_123', eventType: 'payment.captured' })],
rowCount: 1,
});
const result = await service.recordWebhookEventStart(
'test',
'evt_new_123',
'payment.captured',
makeWebhookPayload('payment.captured')
);
expect(result).toMatchObject({
shouldProcess: true,
row: {
eventId: 'evt_new_123',
processingStatus: 'pending',
},
});
expect(mockPool.query).toHaveBeenCalledTimes(1);
expect(mockPool.query).toHaveBeenCalledWith(
expect.stringMatching(
/INSERT INTO payments\.webhook_events[\s\S]*ON CONFLICT \(provider, environment, provider_event_id\) DO NOTHING/i
),
[
'razorpay',
'test',
'evt_new_123',
'payment.captured',
false,
null,
null,
null,
expect.objectContaining({
event: 'payment.captured',
}),
]
);
});
it('does not reprocess fresh pending Razorpay webhook duplicates', async () => {
const service = RazorpayWebhookService.getInstance();
mockPool.query
.mockResolvedValueOnce({ rows: [], rowCount: 0 })
.mockResolvedValueOnce({ rows: [], rowCount: 0 })
.mockResolvedValueOnce({
rows: [
makeWebhookRow({
eventId: 'evt_pending_123',
eventType: 'payment.captured',
processingStatus: 'pending',
}),
],
rowCount: 1,
});
const result = await service.recordWebhookEventStart(
'test',
'evt_pending_123',
'payment.captured',
makeWebhookPayload('payment.captured')
);
expect(result).toMatchObject({
shouldProcess: false,
row: {
eventId: 'evt_pending_123',
processingStatus: 'pending',
},
});
expect(mockPool.query).toHaveBeenCalledTimes(3);
expect(mockPool.query).toHaveBeenNthCalledWith(
2,
expect.stringMatching(
/UPDATE payments\.webhook_events[\s\S]*processing_status = 'failed'[\s\S]*OR \(processing_status = 'pending' AND updated_at < \$5\)/i
),
[
'razorpay',
'test',
'evt_pending_123',
expect.objectContaining({
event: 'payment.captured',
}),
expect.any(Date),
]
);
});
it('reclaims failed or stale Razorpay webhook events for retry', async () => {
const service = RazorpayWebhookService.getInstance();
mockPool.query.mockResolvedValueOnce({ rows: [], rowCount: 0 }).mockResolvedValueOnce({
rows: [
makeWebhookRow({
eventId: 'evt_retry_123',
eventType: 'payment.captured',
processingStatus: 'pending',
attemptCount: 2,
lastError: null,
}),
],
rowCount: 1,
});
const result = await service.recordWebhookEventStart(
'test',
'evt_retry_123',
'payment.captured',
makeWebhookPayload('payment.captured')
);
expect(result).toMatchObject({
shouldProcess: true,
row: {
eventId: 'evt_retry_123',
processingStatus: 'pending',
attemptCount: 2,
},
});
expect(mockPool.query).toHaveBeenCalledTimes(2);
});
it('materializes invoice-only events into Razorpay transactions', async () => {
const service = RazorpayWebhookService.getInstance();
const handled = await getServiceInternals(service).applyRazorpayWebhookEvent('test', {
entity: 'event',
account_id: 'acc_123',
event: 'invoice.expired',
contains: ['invoice'],
payload: {
invoice: {
entity: {
id: 'inv_123',
entity: 'invoice',
type: 'invoice',
description: null,
customer_id: 'cust_123',
customer_details: {
id: 'cust_123',
name: 'Buyer',
email: 'buyer@example.com',
contact: null,
},
order_id: 'order_123',
subscription_id: null,
payment_id: null,
status: 'expired',
amount: 5000,
amount_paid: 0,
amount_due: 5000,
currency: 'INR',
short_url: null,
notes: {
insforge_subject_type: 'team',
insforge_subject_id: 'team_123',
},
line_items: [
{
id: 'line_123',
item_id: 'item_123',
name: 'Setup fee',
description: null,
amount: 5000,
unit_amount: 5000,
quantity: 1,
currency: 'INR',
},
],
paid_at: null,
cancelled_at: null,
expired_at: 1780617600,
issued_at: 1780531200,
created_at: 1780531200,
},
},
},
created_at: 1780617600,
});
expect(handled).toBe(true);
expect(mockPool.query).toHaveBeenCalledWith(
expect.stringMatching(/WITH refs[\s\S]*INSERT INTO payments\.transactions/i),
expect.arrayContaining([
'test',
'invoice',
'inv_123',
'failed_payment',
'failed',
'team',
'team_123',
'cust_123',
'buyer@example.com',
5000,
'inr',
'Setup fee',
])
);
});
it('preserves invoice context when invoice events include a payment entity', async () => {
const service = RazorpayWebhookService.getInstance();
const handled = await getServiceInternals(service).applyRazorpayWebhookEvent('test', {
entity: 'event',
account_id: 'acc_123',
event: 'invoice.paid',
contains: ['invoice', 'payment', 'subscription'],
payload: {
invoice: {
entity: {
id: 'inv_123',
entity: 'invoice',
type: 'invoice',
description: 'Subscription invoice',
customer_id: 'cust_123',
customer_details: {
id: 'cust_123',
name: 'Buyer',
email: 'buyer@example.com',
contact: null,
},
order_id: 'order_123',
subscription_id: 'sub_123',
payment_id: 'pay_123',
status: 'paid',
amount: 5000,
amount_paid: 5000,
amount_due: 0,
currency: 'INR',
short_url: null,
notes: {
insforge_subject_type: 'team',
insforge_subject_id: 'team_123',
},
line_items: [
{
id: 'line_123',
item_id: 'item_123',
name: 'Pro monthly',
description: null,
amount: 5000,
unit_amount: 5000,
quantity: 1,
currency: 'INR',
},
],
paid_at: 1780617600,
cancelled_at: null,
expired_at: null,
issued_at: 1780531200,
created_at: 1780531200,
},
},
payment: {
entity: {
id: 'pay_123',
entity: 'payment',
amount: 5000,
currency: 'INR',
status: 'captured',
order_id: null,
invoice_id: null,
international: false,
method: 'card',
amount_refunded: 0,
refund_status: null,
captured: true,
description: null,
card_id: null,
bank: null,
wallet: null,
vpa: null,
email: 'buyer@example.com',
contact: null,
customer_id: 'cust_123',
notes: {},
fee: null,
tax: null,
error_code: null,
error_description: null,
error_source: null,
error_step: null,
error_reason: null,
created_at: 1780617600,
},
},
subscription: {
entity: {
id: 'sub_123',
entity: 'subscription',
plan_id: 'plan_123',
customer_id: 'cust_123',
status: 'active',
current_start: 1780617600,
current_end: 1783209600,
ended_at: null,
quantity: 1,
notes: {},
charge_at: null,
start_at: null,
end_at: null,
total_count: 12,
auth_attempts: 0,
paid_count: 1,
remaining_count: 11,
short_url: null,
has_scheduled_changes: false,
change_scheduled_at: null,
offer_id: null,
created_at: 1780531200,
},
},
},
created_at: 1780617600,
});
expect(handled).toBe(true);
const transactionCall = mockPool.query.mock.calls.find(([sql]) =>
/WITH refs[\s\S]*INSERT INTO payments\.transactions/i.test(String(sql))
);
expect(transactionCall).toBeDefined();
const transactionParams = transactionCall?.[1];
expect(Array.isArray(transactionParams)).toBe(true);
const params = transactionParams as unknown[];
expect(params).toEqual(
expect.arrayContaining([
'test',
'payment',
'pay_123',
'subscription_invoice',
'succeeded',
'team',
'team_123',
'cust_123',
'buyer@example.com',
5000,
'inr',
'Subscription invoice',
])
);
expect(JSON.parse(String(params[11]))).toEqual(
expect.objectContaining({
payment: 'pay_123',
invoice: 'inv_123',
order: 'order_123',
subscription: 'sub_123',
})
);
});
it('preserves existing subscription invoice context when later payment events are sparse', async () => {
const service = RazorpayWebhookService.getInstance();
mockPool.query.mockImplementation(async (sql: string) => {
if (/SELECT\s+type,\s+subject_type AS "subjectType"/i.test(sql)) {
return {
rows: [
{
type: 'subscription_invoice',
subjectType: 'team',
subjectId: 'team_123',
providerCustomerId: 'cust_123',
customerEmailSnapshot: 'buyer@example.com',
relatedObjectIds: {
payment: 'pay_123',
invoice: 'inv_123',
order: 'order_123',
subscription: 'sub_123',
},
description: 'Pro monthly',
},
],
rowCount: 1,
};
}
return { rows: [], rowCount: 0 };
});
const handled = await getServiceInternals(service).applyRazorpayWebhookEvent('test', {
entity: 'event',
account_id: 'acc_123',
event: 'payment.captured',
contains: ['payment'],
payload: {
payment: {
entity: {
id: 'pay_123',
entity: 'payment',
amount: 5000,
currency: 'INR',
status: 'captured',
order_id: null,
invoice_id: null,
international: false,
method: 'card',
amount_refunded: 0,
refund_status: null,
captured: true,
description: null,
card_id: null,
bank: null,
wallet: null,
vpa: null,
email: null,
contact: null,
customer_id: null,
notes: {},
fee: null,
tax: null,
error_code: null,
error_description: null,
error_source: null,
error_step: null,
error_reason: null,
created_at: 1780617600,
},
},
},
created_at: 1780617600,
});
expect(handled).toBe(true);
const transactionCall = mockPool.query.mock.calls.find(([sql]) =>
/WITH refs[\s\S]*INSERT INTO payments\.transactions/i.test(String(sql))
);
expect(transactionCall).toBeDefined();
const params = transactionCall?.[1] as unknown[];
expect(params).toEqual(
expect.arrayContaining([
'test',
'payment',
'pay_123',
'subscription_invoice',
'succeeded',
'team',
'team_123',
'cust_123',
'buyer@example.com',
5000,
'inr',
'Pro monthly',
])
);
expect(JSON.parse(String(params[11]))).toEqual(
expect.objectContaining({
payment: 'pay_123',
invoice: 'inv_123',
order: 'order_123',
subscription: 'sub_123',
})
);
});
it('records Razorpay refund creation as pending without incrementing blindly', async () => {
const service = RazorpayWebhookService.getInstance();
const handled = await getServiceInternals(service).applyRazorpayWebhookEvent('test', {
entity: 'event',
account_id: 'acc_123',
event: 'refund.created',
contains: ['refund'],
payload: {
refund: {
entity: {
id: 'rfnd_123',
entity: 'refund',
payment_id: 'pay_123',
amount: 1200,
currency: 'INR',
status: 'pending',
created_at: 1780617600,
},
},
},
created_at: 1780617600,
});
expect(handled).toBe(true);
expect(mockPool.query).toHaveBeenCalledWith(
expect.stringMatching(/INSERT INTO payments\.transactions/i),
expect.arrayContaining([
'test',
'refund',
'rfnd_123',
'payment',
'pay_123',
'pending',
1200,
1200,
'inr',
])
);
expect(mockPool.query).toHaveBeenCalledWith(
expect.stringMatching(
/WITH refund_totals[\s\S]*UPDATE payments\.transactions original[\s\S]*related_object_ids = original_context\.related_object_ids \|\| refund\.related_object_ids/i
),
['test', 'pay_123']
);
const executedSql = mockPool.query.mock.calls.map(([sql]) => String(sql)).join('\n');
expect(executedSql).not.toContain('amount_refunded = COALESCE(amount_refunded, 0) +');
expect(executedSql).toContain("AND $7 = 'pending'");
});
it('records partially refunded Razorpay payments as partially_refunded', async () => {
const service = RazorpayWebhookService.getInstance();
const handled = await getServiceInternals(service).applyRazorpayWebhookEvent('test', {
entity: 'event',
account_id: 'acc_123',
event: 'payment.captured',
contains: ['payment'],
payload: {
payment: {
entity: {
id: 'pay_partial_123',
entity: 'payment',
amount: 5000,
currency: 'INR',
status: 'captured',
order_id: 'order_123',
invoice_id: null,
international: false,
method: 'card',
amount_refunded: 1200,
refund_status: 'partial',
captured: true,
description: null,
card_id: null,
bank: null,
wallet: null,
vpa: null,
email: 'buyer@example.com',
contact: null,
customer_id: 'cust_123',
notes: {
insforge_subject_type: 'team',
insforge_subject_id: 'team_123',
},
fee: null,
tax: null,
error_code: null,
error_description: null,
error_source: null,
error_step: null,
error_reason: null,
created_at: 1780617600,
},
},
},
created_at: 1780617600,
});
expect(handled).toBe(true);
expect(mockPool.query).toHaveBeenCalledWith(
expect.stringMatching(/WITH refs[\s\S]*INSERT INTO payments\.transactions/i),
expect.arrayContaining([
'test',
'payment',
'pay_partial_123',
'one_time_payment',
'partially_refunded',
'team',
'team_123',
'cust_123',
'buyer@example.com',
5000,
1200,
'inr',
])
);
const transactionSql = mockPool.query.mock.calls.find(([sql]) =>
/WITH refs[\s\S]*INSERT INTO payments\.transactions/i.test(String(sql))
)?.[0];
expect(String(transactionSql)).toMatch(
/ORDER BY\s+CASE WHEN tx\.provider_object_type = \$2 AND tx\.provider_object_id = \$3 THEN 0 ELSE 1 END,\s+tx\.created_at DESC/i
);
expect(mockPool.query).toHaveBeenCalledWith(
expect.stringMatching(
/UPDATE payments\.razorpay_orders[\s\S]*SET status = CASE[\s\S]*WHEN \$3 THEN 'paid'[\s\S]*WHEN status = 'paid' THEN status[\s\S]*ELSE 'attempted'[\s\S]*verified_payment_id = CASE[\s\S]*WHEN \$3 THEN COALESCE\(verified_payment_id, \$5\)[\s\S]*ELSE verified_payment_id/i
),
expect.arrayContaining(['test', 'order_123', true, 5000, 'pay_partial_123'])
);
});
it('does not mark a Razorpay order verified from a non-succeeded payment', async () => {
const service = RazorpayWebhookService.getInstance();
const handled = await getServiceInternals(service).applyRazorpayWebhookEvent('test', {
entity: 'event',
account_id: 'acc_123',
event: 'payment.failed',
contains: ['payment'],
payload: {
payment: {
entity: {
id: 'pay_failed_123',
entity: 'payment',
amount: 5000,
currency: 'INR',
status: 'failed',
order_id: 'order_123',
invoice_id: null,
international: false,
method: 'card',
amount_refunded: 0,
refund_status: null,
captured: false,
description: null,
card_id: null,
bank: null,
wallet: null,
vpa: null,
email: 'buyer@example.com',
contact: null,
customer_id: 'cust_123',
notes: {},
fee: null,
tax: null,
error_code: 'BAD_REQUEST_ERROR',
error_description: 'Payment failed',
error_source: 'customer',
error_step: 'payment_authentication',
error_reason: 'payment_failed',
created_at: 1780617600,
},
},
},
created_at: 1780617600,
});
expect(handled).toBe(true);
expect(mockPool.query).toHaveBeenCalledWith(
expect.stringMatching(
/UPDATE payments\.razorpay_orders[\s\S]*SET status = CASE[\s\S]*WHEN \$3 THEN 'paid'[\s\S]*WHEN status = 'paid' THEN status[\s\S]*ELSE 'attempted'[\s\S]*verified_payment_id = CASE[\s\S]*WHEN \$3 THEN COALESCE\(verified_payment_id, \$5\)[\s\S]*ELSE verified_payment_id/i
),
expect.arrayContaining(['test', 'order_123', false, 5000, 'pay_failed_123'])
);
});
it('preserves a paid Razorpay order when a late authorized payment event arrives', async () => {
const service = RazorpayWebhookService.getInstance();
const handled = await getServiceInternals(service).applyRazorpayWebhookEvent('test', {
entity: 'event',
account_id: 'acc_123',
event: 'payment.authorized',
contains: ['payment'],
payload: {
payment: {
entity: {
id: 'pay_authorized_123',
entity: 'payment',
amount: 5000,
currency: 'INR',
status: 'authorized',
order_id: 'order_123',
invoice_id: null,
international: false,
method: 'card',
amount_refunded: 0,
refund_status: null,
captured: false,
description: null,
card_id: null,
bank: null,
wallet: null,
vpa: null,
email: 'buyer@example.com',
contact: null,
customer_id: 'cust_123',
notes: {},
fee: null,
tax: null,
error_code: null,
error_description: null,
error_source: null,
error_step: null,
error_reason: null,
created_at: 1780617600,
},
},
},
created_at: 1780617600,
});
expect(handled).toBe(true);
expect(mockPool.query).toHaveBeenCalledWith(
expect.stringMatching(
/UPDATE payments\.razorpay_orders[\s\S]*SET status = CASE[\s\S]*WHEN \$3 THEN 'paid'[\s\S]*WHEN status = 'paid' THEN status[\s\S]*ELSE 'attempted'/i
),
expect.arrayContaining(['test', 'order_123', false, 5000, 'pay_authorized_123'])
);
});
it('marks Razorpay refunds as refunded only after refund.processed', async () => {
const service = RazorpayWebhookService.getInstance();
const handled = await getServiceInternals(service).applyRazorpayWebhookEvent('test', {
entity: 'event',
account_id: 'acc_123',
event: 'refund.processed',
contains: ['refund'],
payload: {
refund: {
entity: {
id: 'rfnd_123',
entity: 'refund',
payment_id: 'pay_123',
amount: 1200,
currency: 'INR',
status: 'processed',
created_at: 1780617600,
processed_at: 1780617900,
},
},
},
created_at: 1780617900,
});
expect(handled).toBe(true);
expect(mockPool.query).toHaveBeenCalledWith(
expect.stringMatching(/INSERT INTO payments\.transactions/i),
expect.arrayContaining([
'test',
'refund',
'rfnd_123',
'payment',
'pay_123',
'refunded',
1200,
1200,
'inr',
])
);
});
});