Phase 0 - Base modules (100% copy): - shared/ (errors, middleware, services, utils, types) - auth, users, tenants (multi-tenancy) - ai, audit, notifications, mcp, payment-terminals - billing-usage, branches, companies, core Phase 1 - Modules to adapt (70-95%): - partners (for shippers/consignees) - inventory (for refacciones) - financial (for transport costing) Phase 2 - Pattern modules (50-70%): - ordenes-transporte (from sales) - gestion-flota (from products) - viajes (from projects) Phase 3 - New transport-specific modules: - tracking (GPS, events, alerts) - tarifas-transporte (pricing, surcharges) - combustible-gastos (fuel, tolls, expenses) - carta-porte (CFDI complement 3.1) Estimated token savings: ~65% (~10,675 lines) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
598 lines
20 KiB
TypeScript
598 lines
20 KiB
TypeScript
import { jest, describe, it, expect, beforeEach } from '@jest/globals';
|
|
import { createMockRepository } from '../../../__tests__/helpers.js';
|
|
|
|
// Mock factories for Stripe entities
|
|
function createMockStripeEvent(overrides: Record<string, any> = {}) {
|
|
return {
|
|
id: 'event-uuid-1',
|
|
stripeEventId: 'evt_1234567890',
|
|
eventType: 'customer.subscription.created',
|
|
apiVersion: '2023-10-16',
|
|
data: {
|
|
object: {
|
|
id: 'sub_123',
|
|
customer: 'cus_123',
|
|
status: 'active',
|
|
current_period_start: Math.floor(Date.now() / 1000),
|
|
current_period_end: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
|
|
},
|
|
},
|
|
processed: false,
|
|
processedAt: null,
|
|
retryCount: 0,
|
|
errorMessage: null,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function createMockSubscription(overrides: Record<string, any> = {}) {
|
|
return {
|
|
id: 'subscription-uuid-1',
|
|
tenantId: 'tenant-uuid-1',
|
|
planId: 'plan-uuid-1',
|
|
status: 'active',
|
|
stripeCustomerId: 'cus_123',
|
|
stripeSubscriptionId: 'sub_123',
|
|
currentPeriodStart: new Date(),
|
|
currentPeriodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
|
|
billingCycle: 'monthly',
|
|
currentPrice: 499,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
// Mock repositories
|
|
const mockEventRepository = createMockRepository();
|
|
const mockSubscriptionRepository = createMockRepository();
|
|
|
|
// Mock DataSource
|
|
const mockDataSource = {
|
|
getRepository: jest.fn((entity: any) => {
|
|
const entityName = entity.name || entity;
|
|
if (entityName === 'StripeEvent') return mockEventRepository;
|
|
if (entityName === 'TenantSubscription') return mockSubscriptionRepository;
|
|
return mockEventRepository;
|
|
}),
|
|
};
|
|
|
|
jest.mock('../../../shared/utils/logger.js', () => ({
|
|
logger: {
|
|
info: jest.fn(),
|
|
error: jest.fn(),
|
|
debug: jest.fn(),
|
|
warn: jest.fn(),
|
|
},
|
|
}));
|
|
|
|
// Import after mocking
|
|
import { StripeWebhookService, StripeWebhookPayload } from '../services/stripe-webhook.service.js';
|
|
|
|
describe('StripeWebhookService', () => {
|
|
let service: StripeWebhookService;
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
service = new StripeWebhookService(mockDataSource as any);
|
|
});
|
|
|
|
describe('processWebhook', () => {
|
|
it('should process a new webhook event successfully', async () => {
|
|
const payload: StripeWebhookPayload = {
|
|
id: 'evt_new_event',
|
|
type: 'customer.subscription.created',
|
|
api_version: '2023-10-16',
|
|
data: {
|
|
object: {
|
|
id: 'sub_new',
|
|
customer: 'cus_123',
|
|
status: 'active',
|
|
current_period_start: Math.floor(Date.now() / 1000),
|
|
current_period_end: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
|
|
},
|
|
},
|
|
created: Math.floor(Date.now() / 1000),
|
|
livemode: false,
|
|
};
|
|
|
|
const mockEvent = createMockStripeEvent({ stripeEventId: 'evt_new_event' });
|
|
const mockSubscription = createMockSubscription();
|
|
|
|
mockEventRepository.findOne.mockResolvedValue(null); // No existing event
|
|
mockEventRepository.create.mockReturnValue(mockEvent);
|
|
mockEventRepository.save.mockResolvedValue(mockEvent);
|
|
mockSubscriptionRepository.findOne.mockResolvedValue(mockSubscription);
|
|
mockSubscriptionRepository.save.mockResolvedValue(mockSubscription);
|
|
|
|
const result = await service.processWebhook(payload);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.message).toBe('Event processed successfully');
|
|
expect(mockEventRepository.create).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should return success for already processed event', async () => {
|
|
const payload: StripeWebhookPayload = {
|
|
id: 'evt_already_processed',
|
|
type: 'customer.subscription.created',
|
|
data: { object: {} },
|
|
created: Math.floor(Date.now() / 1000),
|
|
livemode: false,
|
|
};
|
|
|
|
const existingEvent = createMockStripeEvent({
|
|
stripeEventId: 'evt_already_processed',
|
|
processed: true,
|
|
});
|
|
|
|
mockEventRepository.findOne.mockResolvedValue(existingEvent);
|
|
|
|
const result = await service.processWebhook(payload);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.message).toBe('Event already processed');
|
|
});
|
|
|
|
it('should retry processing for failed event', async () => {
|
|
const payload: StripeWebhookPayload = {
|
|
id: 'evt_failed',
|
|
type: 'customer.subscription.created',
|
|
data: {
|
|
object: {
|
|
id: 'sub_retry',
|
|
customer: 'cus_123',
|
|
status: 'active',
|
|
current_period_start: Math.floor(Date.now() / 1000),
|
|
current_period_end: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
|
|
},
|
|
},
|
|
created: Math.floor(Date.now() / 1000),
|
|
livemode: false,
|
|
};
|
|
|
|
const failedEvent = createMockStripeEvent({
|
|
stripeEventId: 'evt_failed',
|
|
processed: false,
|
|
retryCount: 1,
|
|
data: payload.data,
|
|
});
|
|
const mockSubscription = createMockSubscription();
|
|
|
|
mockEventRepository.findOne.mockResolvedValue(failedEvent);
|
|
mockEventRepository.save.mockResolvedValue(failedEvent);
|
|
mockSubscriptionRepository.findOne.mockResolvedValue(mockSubscription);
|
|
mockSubscriptionRepository.save.mockResolvedValue(mockSubscription);
|
|
|
|
const result = await service.processWebhook(payload);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.message).toBe('Event processed on retry');
|
|
});
|
|
|
|
it('should handle processing errors gracefully', async () => {
|
|
const payload: StripeWebhookPayload = {
|
|
id: 'evt_error',
|
|
type: 'customer.subscription.created',
|
|
data: {
|
|
object: {
|
|
id: 'sub_error',
|
|
customer: 'cus_123',
|
|
status: 'active',
|
|
},
|
|
},
|
|
created: Math.floor(Date.now() / 1000),
|
|
livemode: false,
|
|
};
|
|
|
|
const mockEvent = createMockStripeEvent({ stripeEventId: 'evt_error' });
|
|
|
|
mockEventRepository.findOne.mockResolvedValue(null);
|
|
mockEventRepository.create.mockReturnValue(mockEvent);
|
|
mockEventRepository.save.mockResolvedValue(mockEvent);
|
|
mockSubscriptionRepository.findOne.mockRejectedValue(new Error('Database error'));
|
|
|
|
const result = await service.processWebhook(payload);
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toBe('Database error');
|
|
});
|
|
});
|
|
|
|
describe('handleSubscriptionCreated', () => {
|
|
it('should create/link subscription for existing customer', async () => {
|
|
const payload: StripeWebhookPayload = {
|
|
id: 'evt_sub_created',
|
|
type: 'customer.subscription.created',
|
|
data: {
|
|
object: {
|
|
id: 'sub_new_123',
|
|
customer: 'cus_existing',
|
|
status: 'active',
|
|
current_period_start: Math.floor(Date.now() / 1000),
|
|
current_period_end: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
|
|
trial_end: null,
|
|
},
|
|
},
|
|
created: Math.floor(Date.now() / 1000),
|
|
livemode: false,
|
|
};
|
|
|
|
const mockEvent = createMockStripeEvent();
|
|
const mockSubscription = createMockSubscription({ stripeCustomerId: 'cus_existing' });
|
|
|
|
mockEventRepository.findOne.mockResolvedValue(null);
|
|
mockEventRepository.create.mockReturnValue(mockEvent);
|
|
mockEventRepository.save.mockResolvedValue(mockEvent);
|
|
mockSubscriptionRepository.findOne.mockResolvedValue(mockSubscription);
|
|
mockSubscriptionRepository.save.mockResolvedValue(mockSubscription);
|
|
|
|
const result = await service.processWebhook(payload);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(mockSubscriptionRepository.save).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('handleSubscriptionUpdated', () => {
|
|
it('should update subscription status', async () => {
|
|
const payload: StripeWebhookPayload = {
|
|
id: 'evt_sub_updated',
|
|
type: 'customer.subscription.updated',
|
|
data: {
|
|
object: {
|
|
id: 'sub_123',
|
|
customer: 'cus_123',
|
|
status: 'past_due',
|
|
current_period_start: Math.floor(Date.now() / 1000),
|
|
current_period_end: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
|
|
cancel_at_period_end: false,
|
|
canceled_at: null,
|
|
},
|
|
},
|
|
created: Math.floor(Date.now() / 1000),
|
|
livemode: false,
|
|
};
|
|
|
|
const mockEvent = createMockStripeEvent();
|
|
const mockSubscription = createMockSubscription({ stripeSubscriptionId: 'sub_123' });
|
|
|
|
mockEventRepository.findOne.mockResolvedValue(null);
|
|
mockEventRepository.create.mockReturnValue(mockEvent);
|
|
mockEventRepository.save.mockResolvedValue(mockEvent);
|
|
mockSubscriptionRepository.findOne.mockResolvedValue(mockSubscription);
|
|
mockSubscriptionRepository.save.mockResolvedValue({ ...mockSubscription, status: 'past_due' });
|
|
|
|
const result = await service.processWebhook(payload);
|
|
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it('should handle cancel_at_period_end flag', async () => {
|
|
const payload: StripeWebhookPayload = {
|
|
id: 'evt_sub_cancel_scheduled',
|
|
type: 'customer.subscription.updated',
|
|
data: {
|
|
object: {
|
|
id: 'sub_cancel',
|
|
customer: 'cus_123',
|
|
status: 'active',
|
|
current_period_start: Math.floor(Date.now() / 1000),
|
|
current_period_end: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
|
|
cancel_at_period_end: true,
|
|
canceled_at: null,
|
|
},
|
|
},
|
|
created: Math.floor(Date.now() / 1000),
|
|
livemode: false,
|
|
};
|
|
|
|
const mockEvent = createMockStripeEvent({ eventType: 'customer.subscription.updated' });
|
|
const mockSubscription = createMockSubscription({ stripeSubscriptionId: 'sub_cancel' });
|
|
|
|
mockEventRepository.findOne.mockResolvedValue(null);
|
|
mockEventRepository.create.mockReturnValue(mockEvent);
|
|
mockEventRepository.save.mockResolvedValue(mockEvent);
|
|
mockSubscriptionRepository.findOne.mockResolvedValue(mockSubscription);
|
|
mockSubscriptionRepository.save.mockImplementation((sub) => Promise.resolve(sub));
|
|
|
|
await service.processWebhook(payload);
|
|
|
|
expect(mockSubscriptionRepository.save).toHaveBeenCalledWith(
|
|
expect.objectContaining({ cancelAtPeriodEnd: true })
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('handleSubscriptionDeleted', () => {
|
|
it('should mark subscription as cancelled', async () => {
|
|
const payload: StripeWebhookPayload = {
|
|
id: 'evt_sub_deleted',
|
|
type: 'customer.subscription.deleted',
|
|
data: {
|
|
object: {
|
|
id: 'sub_deleted',
|
|
customer: 'cus_123',
|
|
status: 'canceled',
|
|
},
|
|
},
|
|
created: Math.floor(Date.now() / 1000),
|
|
livemode: false,
|
|
};
|
|
|
|
const mockEvent = createMockStripeEvent();
|
|
const mockSubscription = createMockSubscription({ stripeSubscriptionId: 'sub_deleted' });
|
|
|
|
mockEventRepository.findOne.mockResolvedValue(null);
|
|
mockEventRepository.create.mockReturnValue(mockEvent);
|
|
mockEventRepository.save.mockResolvedValue(mockEvent);
|
|
mockSubscriptionRepository.findOne.mockResolvedValue(mockSubscription);
|
|
mockSubscriptionRepository.save.mockImplementation((sub) => Promise.resolve(sub));
|
|
|
|
await service.processWebhook(payload);
|
|
|
|
expect(mockSubscriptionRepository.save).toHaveBeenCalledWith(
|
|
expect.objectContaining({ status: 'cancelled' })
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('handlePaymentSucceeded', () => {
|
|
it('should update subscription with payment info', async () => {
|
|
const payload: StripeWebhookPayload = {
|
|
id: 'evt_payment_success',
|
|
type: 'invoice.payment_succeeded',
|
|
data: {
|
|
object: {
|
|
id: 'inv_123',
|
|
customer: 'cus_123',
|
|
amount_paid: 49900, // $499.00 in cents
|
|
subscription: 'sub_123',
|
|
},
|
|
},
|
|
created: Math.floor(Date.now() / 1000),
|
|
livemode: false,
|
|
};
|
|
|
|
const mockEvent = createMockStripeEvent({ eventType: 'invoice.payment_succeeded' });
|
|
const mockSubscription = createMockSubscription({ stripeCustomerId: 'cus_123' });
|
|
|
|
mockEventRepository.findOne.mockResolvedValue(null);
|
|
mockEventRepository.create.mockReturnValue(mockEvent);
|
|
mockEventRepository.save.mockResolvedValue(mockEvent);
|
|
mockSubscriptionRepository.findOne.mockResolvedValue(mockSubscription);
|
|
mockSubscriptionRepository.save.mockImplementation((sub) => Promise.resolve(sub));
|
|
|
|
await service.processWebhook(payload);
|
|
|
|
expect(mockSubscriptionRepository.save).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
status: 'active',
|
|
lastPaymentAmount: 499, // Converted from cents
|
|
})
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('handlePaymentFailed', () => {
|
|
it('should mark subscription as past_due', async () => {
|
|
const payload: StripeWebhookPayload = {
|
|
id: 'evt_payment_failed',
|
|
type: 'invoice.payment_failed',
|
|
data: {
|
|
object: {
|
|
id: 'inv_failed',
|
|
customer: 'cus_123',
|
|
attempt_count: 1,
|
|
},
|
|
},
|
|
created: Math.floor(Date.now() / 1000),
|
|
livemode: false,
|
|
};
|
|
|
|
const mockEvent = createMockStripeEvent({ eventType: 'invoice.payment_failed' });
|
|
const mockSubscription = createMockSubscription({ stripeCustomerId: 'cus_123', status: 'active' });
|
|
|
|
mockEventRepository.findOne.mockResolvedValue(null);
|
|
mockEventRepository.create.mockReturnValue(mockEvent);
|
|
mockEventRepository.save.mockResolvedValue(mockEvent);
|
|
mockSubscriptionRepository.findOne.mockResolvedValue(mockSubscription);
|
|
mockSubscriptionRepository.save.mockImplementation((sub) => Promise.resolve(sub));
|
|
|
|
await service.processWebhook(payload);
|
|
|
|
expect(mockSubscriptionRepository.save).toHaveBeenCalledWith(
|
|
expect.objectContaining({ status: 'past_due' })
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('handleCheckoutCompleted', () => {
|
|
it('should link Stripe customer to tenant', async () => {
|
|
const payload: StripeWebhookPayload = {
|
|
id: 'evt_checkout_completed',
|
|
type: 'checkout.session.completed',
|
|
data: {
|
|
object: {
|
|
id: 'cs_123',
|
|
customer: 'cus_new',
|
|
subscription: 'sub_new',
|
|
metadata: {
|
|
tenant_id: 'tenant-uuid-1',
|
|
},
|
|
},
|
|
},
|
|
created: Math.floor(Date.now() / 1000),
|
|
livemode: false,
|
|
};
|
|
|
|
const mockEvent = createMockStripeEvent({ eventType: 'checkout.session.completed' });
|
|
const mockSubscription = createMockSubscription({ tenantId: 'tenant-uuid-1' });
|
|
|
|
mockEventRepository.findOne.mockResolvedValue(null);
|
|
mockEventRepository.create.mockReturnValue(mockEvent);
|
|
mockEventRepository.save.mockResolvedValue(mockEvent);
|
|
mockSubscriptionRepository.findOne.mockResolvedValue(mockSubscription);
|
|
mockSubscriptionRepository.save.mockImplementation((sub) => Promise.resolve(sub));
|
|
|
|
await service.processWebhook(payload);
|
|
|
|
expect(mockSubscriptionRepository.save).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
stripeCustomerId: 'cus_new',
|
|
stripeSubscriptionId: 'sub_new',
|
|
status: 'active',
|
|
})
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('retryProcessing', () => {
|
|
it('should retry and succeed', async () => {
|
|
const failedEvent = createMockStripeEvent({
|
|
processed: false,
|
|
retryCount: 2,
|
|
data: {
|
|
object: {
|
|
id: 'sub_retry',
|
|
customer: 'cus_123',
|
|
status: 'active',
|
|
current_period_start: Math.floor(Date.now() / 1000),
|
|
current_period_end: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
|
|
},
|
|
},
|
|
});
|
|
const mockSubscription = createMockSubscription();
|
|
|
|
mockSubscriptionRepository.findOne.mockResolvedValue(mockSubscription);
|
|
mockSubscriptionRepository.save.mockResolvedValue(mockSubscription);
|
|
mockEventRepository.save.mockResolvedValue(failedEvent);
|
|
|
|
const result = await service.retryProcessing(failedEvent as any);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.message).toBe('Event processed on retry');
|
|
});
|
|
|
|
it('should fail if max retries exceeded', async () => {
|
|
const maxRetriedEvent = createMockStripeEvent({
|
|
processed: false,
|
|
retryCount: 5,
|
|
errorMessage: 'Previous error',
|
|
});
|
|
|
|
const result = await service.retryProcessing(maxRetriedEvent as any);
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.message).toBe('Max retries exceeded');
|
|
});
|
|
});
|
|
|
|
describe('getFailedEvents', () => {
|
|
it('should return unprocessed events', async () => {
|
|
const failedEvents = [
|
|
createMockStripeEvent({ processed: false }),
|
|
createMockStripeEvent({ processed: false, stripeEventId: 'evt_2' }),
|
|
];
|
|
|
|
mockEventRepository.find.mockResolvedValue(failedEvents);
|
|
|
|
const result = await service.getFailedEvents();
|
|
|
|
expect(result).toHaveLength(2);
|
|
expect(mockEventRepository.find).toHaveBeenCalledWith({
|
|
where: { processed: false },
|
|
order: { createdAt: 'ASC' },
|
|
take: 100,
|
|
});
|
|
});
|
|
|
|
it('should respect limit parameter', async () => {
|
|
mockEventRepository.find.mockResolvedValue([]);
|
|
|
|
await service.getFailedEvents(50);
|
|
|
|
expect(mockEventRepository.find).toHaveBeenCalledWith(
|
|
expect.objectContaining({ take: 50 })
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('findByStripeEventId', () => {
|
|
it('should find event by Stripe ID', async () => {
|
|
const mockEvent = createMockStripeEvent({ stripeEventId: 'evt_find' });
|
|
mockEventRepository.findOne.mockResolvedValue(mockEvent);
|
|
|
|
const result = await service.findByStripeEventId('evt_find');
|
|
|
|
expect(result).toBeDefined();
|
|
expect(result?.stripeEventId).toBe('evt_find');
|
|
});
|
|
|
|
it('should return null if not found', async () => {
|
|
mockEventRepository.findOne.mockResolvedValue(null);
|
|
|
|
const result = await service.findByStripeEventId('evt_notfound');
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('getRecentEvents', () => {
|
|
it('should return recent events with default options', async () => {
|
|
const mockEvents = [createMockStripeEvent()];
|
|
const mockQueryBuilder = {
|
|
andWhere: jest.fn().mockReturnThis(),
|
|
orderBy: jest.fn().mockReturnThis(),
|
|
take: jest.fn().mockReturnThis(),
|
|
getMany: jest.fn().mockResolvedValue(mockEvents),
|
|
};
|
|
|
|
mockEventRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
|
|
|
const result = await service.getRecentEvents();
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(mockQueryBuilder.take).toHaveBeenCalledWith(50);
|
|
});
|
|
|
|
it('should filter by event type', async () => {
|
|
const mockQueryBuilder = {
|
|
andWhere: jest.fn().mockReturnThis(),
|
|
orderBy: jest.fn().mockReturnThis(),
|
|
take: jest.fn().mockReturnThis(),
|
|
getMany: jest.fn().mockResolvedValue([]),
|
|
};
|
|
|
|
mockEventRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
|
|
|
await service.getRecentEvents({ eventType: 'invoice.payment_succeeded' });
|
|
|
|
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
|
|
'event.eventType = :eventType',
|
|
{ eventType: 'invoice.payment_succeeded' }
|
|
);
|
|
});
|
|
|
|
it('should filter by processed status', async () => {
|
|
const mockQueryBuilder = {
|
|
andWhere: jest.fn().mockReturnThis(),
|
|
orderBy: jest.fn().mockReturnThis(),
|
|
take: jest.fn().mockReturnThis(),
|
|
getMany: jest.fn().mockResolvedValue([]),
|
|
};
|
|
|
|
mockEventRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
|
|
|
await service.getRecentEvents({ processed: false });
|
|
|
|
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
|
|
'event.processed = :processed',
|
|
{ processed: false }
|
|
);
|
|
});
|
|
});
|
|
});
|