575 lines
17 KiB
TypeScript
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);
|
|
});
|
|
});
|