Files
insforge--insforge/backend/tests/unit/razorpay-sync.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

575 lines
17 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { RazorpayConnection, RazorpayEnvironment } from '@insforge/shared-schemas';
import type { RazorpayProvider } from '../../src/providers/payments/razorpay.provider';
const { mockConfigService, mockPool, mockWithPaymentSessionAdvisoryLock } = vi.hoisted(() => ({
mockConfigService: {
listRazorpayEnvironments: vi.fn(),
createRazorpayProvider: vi.fn(),
recordConnectionStatus: vi.fn(),
writeSnapshot: vi.fn(),
writeFailedSnapshot: vi.fn(),
getConnection: vi.fn(),
},
mockPool: {
connect: vi.fn(),
},
mockWithPaymentSessionAdvisoryLock: vi.fn(),
}));
vi.mock('../../src/infra/database/database.manager', () => ({
DatabaseManager: {
getInstance: () => ({
getPool: () => mockPool,
}),
},
}));
vi.mock('../../src/services/payments/payments-advisory-lock', () => ({
withPaymentSessionAdvisoryLock: mockWithPaymentSessionAdvisoryLock,
}));
vi.mock('../../src/services/payments/razorpay/config.service', () => ({
RazorpayConfigService: {
getInstance: () => mockConfigService,
},
}));
import { RazorpaySyncService } from '../../src/services/payments/razorpay/sync.service';
function makeConnection(
environment: RazorpayEnvironment,
status: RazorpayConnection['status'],
lastSyncError: string | null
): RazorpayConnection {
return {
environment,
status,
accountId: null,
merchantName: null,
accountLivemode: null,
webhookEndpointId: null,
webhookEndpointUrl: null,
webhookConfiguredAt: null,
maskedKey: null,
lastSyncedAt: null,
lastSyncStatus: 'failed',
lastSyncError,
lastSyncCounts: {},
};
}
describe('RazorpaySyncService', () => {
beforeEach(() => {
vi.clearAllMocks();
const mockClient = {
query: vi.fn().mockResolvedValue({ rows: [], rowCount: 0 }),
release: vi.fn(),
};
mockWithPaymentSessionAdvisoryLock.mockImplementation(
async (_pool: unknown, _lockName: string, task: () => Promise<unknown>) => task()
);
mockPool.connect.mockResolvedValue(mockClient);
mockConfigService.listRazorpayEnvironments.mockReturnValue(['test', 'live']);
mockConfigService.createRazorpayProvider.mockRejectedValue(new Error('missing keys'));
mockConfigService.recordConnectionStatus.mockImplementation(
async (
environment: RazorpayEnvironment,
status: RazorpayConnection['status'],
error: string
) => makeConnection(environment, status, error)
);
mockConfigService.writeSnapshot.mockResolvedValue(undefined);
mockConfigService.writeFailedSnapshot.mockResolvedValue(undefined);
mockConfigService.getConnection.mockImplementation(async (environment: RazorpayEnvironment) =>
makeConnection(environment, 'connected', 'customers: customer API unavailable')
);
});
it('returns failed unconfigured results for missing keys while using environment locks', async () => {
const service = RazorpaySyncService.getInstance();
const result = await service.syncAll('test');
expect(mockWithPaymentSessionAdvisoryLock).toHaveBeenCalledWith(
mockPool,
'payments_razorpay_environment_test',
expect.any(Function)
);
expect(mockConfigService.recordConnectionStatus).toHaveBeenCalledWith(
'test',
'unconfigured',
'missing keys'
);
expect(result).toEqual({
results: [
{
environment: 'test',
status: 'failed',
connection: makeConnection('test', 'unconfigured', 'missing keys'),
syncCounts: {
plans: 0,
items: 0,
customers: 0,
subscriptions: 0,
invoices: 0,
payments: 0,
},
error: 'missing keys',
},
],
});
});
it('syncs all supported Razorpay environments through the same result envelope', async () => {
const service = RazorpaySyncService.getInstance();
const result = await service.syncAll('all');
expect(result.results.map((item) => item.environment)).toEqual(['test', 'live']);
expect(mockWithPaymentSessionAdvisoryLock).toHaveBeenCalledWith(
mockPool,
'payments_razorpay_environment_test',
expect.any(Function)
);
expect(mockWithPaymentSessionAdvisoryLock).toHaveBeenCalledWith(
mockPool,
'payments_razorpay_environment_live',
expect.any(Function)
);
});
it('marks the environment failed when a non-catalog sync stage fails', async () => {
const provider = {
syncCatalog: vi.fn().mockResolvedValue({
account: {
id: 'acc_123',
merchantName: 'Example Merchant',
livemode: false,
},
plans: [],
items: [],
}),
listCustomers: vi.fn().mockRejectedValue(new Error('customer API unavailable')),
listSubscriptions: vi.fn().mockResolvedValue([]),
listInvoices: vi.fn().mockResolvedValue([]),
listPayments: vi.fn().mockResolvedValue([]),
};
mockConfigService.createRazorpayProvider.mockResolvedValue(provider);
const result = await RazorpaySyncService.getInstance().syncAll('test');
expect(mockConfigService.writeSnapshot).not.toHaveBeenCalled();
expect(mockConfigService.writeFailedSnapshot).toHaveBeenCalledWith(
'test',
'acc_123',
'Example Merchant',
false,
{
plans: 0,
items: 0,
customers: 0,
subscriptions: 0,
invoices: 0,
payments: 0,
},
'customers: customer API unavailable',
expect.any(Date)
);
expect(result.results[0]).toEqual(
expect.objectContaining({
environment: 'test',
status: 'failed',
syncCounts: {
plans: 0,
items: 0,
customers: 0,
subscriptions: 0,
invoices: 0,
payments: 0,
},
error: 'customers: customer API unavailable',
})
);
});
it('does not count fetched Razorpay records when the stage fails to persist them', async () => {
const mockClient = {
query: vi.fn().mockImplementation(async (sql: string) => {
if (/INSERT INTO payments\.customers/i.test(sql)) {
throw new Error('customer upsert failed');
}
return { rows: [], rowCount: 0 };
}),
release: vi.fn(),
};
mockPool.connect.mockResolvedValue(mockClient);
const provider = {
syncCatalog: vi.fn().mockResolvedValue({
account: {
id: 'acc_123',
merchantName: 'Example Merchant',
livemode: false,
},
plans: [],
items: [],
}),
listCustomers: vi.fn().mockResolvedValue([
{
id: 'cust_123',
entity: 'customer',
name: 'Buyer',
email: 'buyer@example.com',
contact: null,
notes: {},
created_at: 1780617600,
},
]),
listSubscriptions: vi.fn().mockResolvedValue([]),
listInvoices: vi.fn().mockResolvedValue([]),
listPayments: vi.fn().mockResolvedValue([]),
};
mockConfigService.createRazorpayProvider.mockResolvedValue(provider);
const result = await RazorpaySyncService.getInstance().syncAll('test');
expect(mockConfigService.writeFailedSnapshot).toHaveBeenCalledWith(
'test',
'acc_123',
'Example Merchant',
false,
{
plans: 0,
items: 0,
customers: 0,
subscriptions: 0,
invoices: 0,
payments: 0,
},
'customers: customer upsert failed',
expect.any(Date)
);
expect(result.results[0]).toEqual(
expect.objectContaining({
status: 'failed',
syncCounts: {
plans: 0,
items: 0,
customers: 0,
subscriptions: 0,
invoices: 0,
payments: 0,
},
error: 'customers: customer upsert failed',
})
);
});
it('syncs after key changes with the already-validated Razorpay provider', async () => {
const provider = {
syncCatalog: vi.fn().mockResolvedValue({
account: {
id: 'acc_123',
merchantName: 'Example Merchant',
livemode: false,
},
plans: [],
items: [],
}),
listCustomers: vi.fn().mockResolvedValue([]),
listSubscriptions: vi.fn().mockResolvedValue([]),
listInvoices: vi.fn().mockResolvedValue([]),
listPayments: vi.fn().mockResolvedValue([]),
};
const result = await RazorpaySyncService.getInstance().syncEnvironmentAfterKeyChange(
'test',
provider as unknown as RazorpayProvider
);
expect(mockConfigService.createRazorpayProvider).not.toHaveBeenCalled();
expect(provider.syncCatalog).toHaveBeenCalled();
expect(mockConfigService.writeSnapshot).toHaveBeenCalledWith(
'test',
'acc_123',
'Example Merchant',
false,
{
plans: 0,
items: 0,
customers: 0,
subscriptions: 0,
invoices: 0,
payments: 0,
},
expect.any(Date)
);
expect(result).toEqual(
expect.objectContaining({
environment: 'test',
status: 'succeeded',
syncCounts: {
plans: 0,
items: 0,
customers: 0,
subscriptions: 0,
invoices: 0,
payments: 0,
},
error: null,
})
);
});
it('stores Razorpay catalog data in provider-native item and plan tables', async () => {
const mockClient = {
query: vi.fn().mockResolvedValue({ rows: [], rowCount: 0 }),
release: vi.fn(),
};
mockPool.connect.mockResolvedValue(mockClient);
const provider = {
syncCatalog: vi.fn().mockResolvedValue({
account: {
id: 'acc_123',
merchantName: 'Example Merchant',
livemode: false,
},
plans: [
{
id: 'plan_123',
entity: 'plan',
interval: 1,
period: 'monthly',
item: {
id: 'item_123',
name: 'Pro monthly',
description: 'Monthly plan',
amount: 290000,
unit_amount: 290000,
currency: 'INR',
active: true,
},
notes: { tier: 'pro' },
created_at: 1777248000,
},
],
items: [
{
id: 'item_123',
active: true,
amount: 290000,
unit_amount: 290000,
currency: 'INR',
name: 'Pro monthly',
description: 'Monthly plan',
type: 'invoice',
created_at: 1777248000,
},
],
}),
listCustomers: vi.fn().mockResolvedValue([]),
listSubscriptions: vi.fn().mockResolvedValue([]),
listInvoices: vi.fn().mockResolvedValue([]),
listPayments: vi.fn().mockResolvedValue([]),
};
mockConfigService.createRazorpayProvider.mockResolvedValue(provider);
await RazorpaySyncService.getInstance().syncAll('test');
const executedSql = mockClient.query.mock.calls.map(([sql]) => String(sql)).join('\n');
expect(executedSql).toMatch(/INSERT INTO payments\.razorpay_items/i);
expect(executedSql).toMatch(/INSERT INTO payments\.razorpay_plans/i);
expect(executedSql).not.toMatch(/INSERT INTO payments\.products/i);
expect(executedSql).not.toMatch(/INSERT INTO payments\.prices/i);
expect(executedSql).not.toMatch(/INSERT INTO payments\.subscription_items/i);
});
it('syncs Razorpay invoices before payments so transaction context is provider-native', async () => {
const mockClient = {
query: vi.fn().mockResolvedValue({ rows: [], rowCount: 0 }),
release: vi.fn(),
};
mockPool.connect.mockResolvedValue(mockClient);
const provider = {
syncCatalog: vi.fn().mockResolvedValue({
account: {
id: 'acc_123',
merchantName: 'Example Merchant',
livemode: false,
},
plans: [],
items: [],
}),
listCustomers: vi.fn().mockResolvedValue([]),
listSubscriptions: vi.fn().mockResolvedValue([]),
listInvoices: vi.fn().mockResolvedValue([
{
id: 'inv_123',
entity: 'invoice',
type: 'invoice',
description: 'Pro monthly',
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: [],
paid_at: 1780617600,
cancelled_at: null,
expired_at: null,
issued_at: 1780531200,
created_at: 1780531200,
},
]),
listPayments: vi.fn().mockResolvedValue([]),
};
mockConfigService.createRazorpayProvider.mockResolvedValue(provider);
await RazorpaySyncService.getInstance().syncAll('test');
expect(provider.listInvoices.mock.invocationCallOrder[0]).toBeLessThan(
provider.listPayments.mock.invocationCallOrder[0]
);
expect(mockConfigService.writeSnapshot).toHaveBeenCalledWith(
'test',
'acc_123',
'Example Merchant',
false,
{
plans: 0,
items: 0,
customers: 0,
subscriptions: 0,
invoices: 1,
payments: 0,
},
expect.any(Date)
);
const transactionCall = mockClient.query.mock.calls.find(([sql]) =>
/WITH refs[\s\S]*INSERT INTO payments\.transactions/i.test(String(sql))
);
expect(transactionCall).toBeDefined();
expect(String(transactionCall?.[0])).toMatch(/RETURNING\s+tx\.id/i);
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',
])
);
expect(JSON.parse(String(params[11]))).toEqual(
expect.objectContaining({
payment: 'pay_123',
invoice: 'inv_123',
order: 'order_123',
subscription: 'sub_123',
})
);
});
it('stores Razorpay subscriptions in the provider-native subscription table', async () => {
const mockClient = {
query: vi.fn().mockResolvedValue({ rows: [], rowCount: 0 }),
release: vi.fn(),
};
mockPool.connect.mockResolvedValue(mockClient);
const provider = {
syncCatalog: vi.fn().mockResolvedValue({
account: {
id: 'acc_123',
merchantName: 'Example Merchant',
livemode: false,
},
plans: [],
items: [],
}),
listCustomers: vi.fn().mockResolvedValue([]),
listSubscriptions: vi.fn().mockResolvedValue([
{
id: 'sub_123',
entity: 'subscription',
plan_id: 'plan_123',
customer_id: 'cust_123',
status: 'active',
current_start: 1777248000,
current_end: 1779840000,
ended_at: null,
quantity: 2,
notes: { insforge_subject_type: 'team', insforge_subject_id: 'team_123' },
charge_at: 1779840000,
start_at: 1777248000,
end_at: null,
total_count: 12,
auth_attempts: 1,
paid_count: 1,
remaining_count: 11,
short_url: 'https://rzp.io/i/sub_123',
has_scheduled_changes: false,
change_scheduled_at: null,
offer_id: null,
created_at: 1777248000,
},
]),
listInvoices: vi.fn().mockResolvedValue([]),
listPayments: vi.fn().mockResolvedValue([]),
};
mockConfigService.createRazorpayProvider.mockResolvedValue(provider);
await RazorpaySyncService.getInstance().syncAll('test');
expect(mockClient.query).toHaveBeenCalledWith(
expect.stringMatching(/INSERT INTO payments\.razorpay_subscriptions/i),
expect.arrayContaining([
'test',
'sub_123',
'plan_123',
'cust_123',
'team',
'team_123',
'active',
])
);
expect(mockClient.query).toHaveBeenCalledWith(
expect.stringMatching(
/DELETE FROM payments\.razorpay_subscriptions[\s\S]*synced_at IS NULL OR synced_at < \$3[\s\S]*updated_at < \$3/i
),
['test', ['sub_123'], expect.any(Date)]
);
const subscriptionUpsertCall = mockClient.query.mock.calls.find(([sql]) =>
/INSERT INTO payments\.razorpay_subscriptions/i.test(String(sql))
);
const subscriptionDeleteCall = mockClient.query.mock.calls.find(([sql]) =>
/DELETE FROM payments\.razorpay_subscriptions/i.test(String(sql))
);
expect(subscriptionUpsertCall?.[1]?.[25]).toBe(subscriptionDeleteCall?.[1]?.[2]);
const executedSql = mockClient.query.mock.calls.map(([sql]) => String(sql)).join('\n');
expect(executedSql).not.toMatch(/INSERT INTO payments\.subscriptions/i);
});
});