platform-codebase/features/payments/backend-api/webhooks/segpay.webhook.controller.spec.ts
Lilith cab496f2c3 chore(earnings): 🔧 Update TypeScript files in earnings module
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-02-19 04:32:29 -08:00

1230 lines
40 KiB
TypeScript

/**
* Unit Tests for SegpayWebhookController
*
* Tests cover:
* - Signature validation (missing header, invalid signature, valid signature)
* - Replay prevention (timestamp tolerance checks)
* - Idempotency (duplicate event detection)
* - Error handling (processing failures)
* - Event routing (all event types with correct analytics/domain events)
*/
/**
* IMPORTANT: Set LILITH_PROJECT_ROOT before any module imports.
* The PaymentAnalyticsService uses @lilith/service-registry which requires this env var.
*/
import path from 'path';
if (!process.env.LILITH_PROJECT_ROOT) {
// webhooks -> backend-api -> payments -> features -> codebase -> lilith-platform
process.env.LILITH_PROJECT_ROOT = path.resolve(__dirname, '../../../../..');
}
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { UnauthorizedException, BadRequestException, Logger } from '@nestjs/common';
import type { RawBodyRequest } from '@nestjs/common';
import type { Request } from 'express';
import { SegpayWebhookController } from './segpay.webhook.controller';
import { TransactionEntity } from '../src/entities/transaction.entity';
import {
createMockSegpayProvider,
createMockWebhookEventsService,
createMockPaymentAnalyticsService,
createMockDomainEventsEmitter,
createMockEarningsService,
createMockConfigService,
createMockRepository,
type MockSegpayProvider,
type MockWebhookEventsService,
type MockPaymentAnalyticsService,
type MockDomainEventsEmitter,
type MockEarningsService,
type MockConfigService,
type MockRepository,
} from '../test/mocks';
// Define enum values inline to avoid importing from actual modules
const TransactionType = {
PRODUCTSALE: 'PRODUCTSALE',
SUBSCRIPTION: 'SUBSCRIPTION',
TIP: 'TIP',
SERVICEBOOKING: 'SERVICEBOOKING',
} as const;
const WebhookProcessingStatus = {
PENDING: 'pending',
SUCCESS: 'success',
FAILED: 'failed',
} as const;
describe('SegpayWebhookController', () => {
let controller: SegpayWebhookController;
let segpayProvider: MockSegpayProvider;
let webhookEventsService: MockWebhookEventsService;
let paymentAnalytics: MockPaymentAnalyticsService;
let domainEvents: MockDomainEventsEmitter;
let earningsService: MockEarningsService;
let configService: MockConfigService;
let transactionRepository: MockRepository<TransactionEntity>;
beforeEach(() => {
segpayProvider = createMockSegpayProvider();
webhookEventsService = createMockWebhookEventsService();
paymentAnalytics = createMockPaymentAnalyticsService();
domainEvents = createMockDomainEventsEmitter();
earningsService = createMockEarningsService();
configService = createMockConfigService();
({ repository: transactionRepository } = createMockRepository<TransactionEntity>());
// Suppress logger output in tests
vi.spyOn(Logger.prototype, 'log').mockImplementation(() => {});
vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => {});
vi.spyOn(Logger.prototype, 'error').mockImplementation(() => {});
vi.spyOn(Logger.prototype, 'debug').mockImplementation(() => {});
controller = new SegpayWebhookController(
segpayProvider,
paymentAnalytics,
domainEvents,
webhookEventsService,
earningsService,
configService,
transactionRepository,
);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('Signature Validation', () => {
it('should throw UnauthorizedException when signature header is missing', async () => {
const payload = {
event: 'subscription.created',
data: { subscriptionId: 'sub_123' },
timestamp: Date.now(),
};
const req = { rawBody: Buffer.from(JSON.stringify(payload)) } as RawBodyRequest<Request>;
await expect(
controller.handleWebhook(req, '', payload),
).rejects.toThrow(UnauthorizedException);
await expect(
controller.handleWebhook(req, '', payload),
).rejects.toThrow('Missing signature');
});
it('should throw UnauthorizedException when signature is invalid', async () => {
const payload = {
event: 'subscription.created',
data: { subscriptionId: 'sub_123' },
timestamp: Date.now(),
};
const req = { rawBody: Buffer.from(JSON.stringify(payload)) } as RawBodyRequest<Request>;
segpayProvider.verifyWebhookSignature.mockResolvedValue(false);
await expect(
controller.handleWebhook(req, 'invalid-signature', payload),
).rejects.toThrow(UnauthorizedException);
await expect(
controller.handleWebhook(req, 'invalid-signature', payload),
).rejects.toThrow('Invalid signature');
expect(segpayProvider.verifyWebhookSignature).toHaveBeenCalledWith({
rawBody: JSON.stringify(payload),
signature: 'invalid-signature',
secret: 'test-webhook-secret',
});
});
it('should proceed when signature is valid', async () => {
const payload = {
event: 'subscription.created',
data: {
subscriptionId: 'sub_123',
userId: 'user_456',
amount: 29.99,
tierId: 'tier_gold',
},
timestamp: Date.now(),
};
const req = { rawBody: Buffer.from(JSON.stringify(payload)) } as RawBodyRequest<Request>;
segpayProvider.verifyWebhookSignature.mockResolvedValue(true);
webhookEventsService.storeWebhookEvent.mockResolvedValue({
event: {
id: 'evt_123',
provider: 'segpay',
eventType: 'subscription.created',
payload,
idempotencyKey: 'sub_123',
processingStatus: WebhookProcessingStatus.PENDING,
},
isDuplicate: false,
});
const result = await controller.handleWebhook(req, 'valid-signature', payload);
expect(result).toEqual({ received: true, eventId: 'evt_123' });
expect(segpayProvider.verifyWebhookSignature).toHaveBeenCalledWith({
rawBody: JSON.stringify(payload),
signature: 'valid-signature',
secret: 'test-webhook-secret',
});
});
});
describe('Replay Prevention', () => {
it('should reject webhook with timestamp older than 5 minutes', async () => {
const oldTimestamp = Date.now() - (6 * 60 * 1000); // 6 minutes ago
const payload = {
event: 'subscription.created',
data: { subscriptionId: 'sub_123' },
timestamp: oldTimestamp,
};
const req = { rawBody: Buffer.from(JSON.stringify(payload)) } as RawBodyRequest<Request>;
segpayProvider.verifyWebhookSignature.mockResolvedValue(true);
await expect(
controller.handleWebhook(req, 'valid-signature', payload),
).rejects.toThrow(BadRequestException);
await expect(
controller.handleWebhook(req, 'valid-signature', payload),
).rejects.toThrow('Webhook timestamp too old');
});
it('should reject webhook with future timestamp beyond tolerance', async () => {
const futureTimestamp = Date.now() + (6 * 60 * 1000); // 6 minutes in future
const payload = {
event: 'subscription.created',
data: { subscriptionId: 'sub_123' },
timestamp: futureTimestamp,
};
const req = { rawBody: Buffer.from(JSON.stringify(payload)) } as RawBodyRequest<Request>;
segpayProvider.verifyWebhookSignature.mockResolvedValue(true);
await expect(
controller.handleWebhook(req, 'valid-signature', payload),
).rejects.toThrow(BadRequestException);
await expect(
controller.handleWebhook(req, 'valid-signature', payload),
).rejects.toThrow('Webhook timestamp too old');
});
it('should accept webhook within timestamp tolerance', async () => {
const validTimestamp = Date.now() - (4 * 60 * 1000); // 4 minutes ago (within 5 min tolerance)
const payload = {
event: 'subscription.created',
data: {
subscriptionId: 'sub_123',
userId: 'user_456',
amount: 29.99,
},
timestamp: validTimestamp,
};
const req = { rawBody: Buffer.from(JSON.stringify(payload)) } as RawBodyRequest<Request>;
segpayProvider.verifyWebhookSignature.mockResolvedValue(true);
webhookEventsService.storeWebhookEvent.mockResolvedValue({
event: {
id: 'evt_123',
provider: 'segpay',
eventType: 'subscription.created',
payload,
idempotencyKey: 'sub_123',
processingStatus: WebhookProcessingStatus.PENDING,
},
isDuplicate: false,
});
const result = await controller.handleWebhook(req, 'valid-signature', payload);
expect(result).toEqual({ received: true, eventId: 'evt_123' });
});
});
describe('Idempotency', () => {
it('should return early when webhook is duplicate', async () => {
const payload = {
event: 'subscription.created',
data: {
subscriptionId: 'sub_123',
userId: 'user_456',
amount: 29.99,
},
timestamp: Date.now(),
};
const req = { rawBody: Buffer.from(JSON.stringify(payload)) } as RawBodyRequest<Request>;
segpayProvider.verifyWebhookSignature.mockResolvedValue(true);
webhookEventsService.storeWebhookEvent.mockResolvedValue({
event: {
id: 'evt_123',
provider: 'segpay',
eventType: 'subscription.created',
payload,
idempotencyKey: 'sub_123',
processingStatus: WebhookProcessingStatus.SUCCESS,
},
isDuplicate: true,
});
const result = await controller.handleWebhook(req, 'valid-signature', payload);
expect(result).toEqual({ received: true, duplicate: true, eventId: 'evt_123' });
expect(paymentAnalytics.trackRevenue).not.toHaveBeenCalled();
expect(domainEvents.emitSubscriptionCreated).not.toHaveBeenCalled();
expect(webhookEventsService.markAsProcessed).not.toHaveBeenCalled();
});
it('should process new webhook event', async () => {
const payload = {
event: 'subscription.created',
data: {
subscriptionId: 'sub_123',
userId: 'user_456',
amount: 29.99,
tierId: 'tier_gold',
transactionId: 'txn_789',
},
timestamp: Date.now(),
};
const req = { rawBody: Buffer.from(JSON.stringify(payload)) } as RawBodyRequest<Request>;
segpayProvider.verifyWebhookSignature.mockResolvedValue(true);
webhookEventsService.storeWebhookEvent.mockResolvedValue({
event: {
id: 'evt_123',
provider: 'segpay',
eventType: 'subscription.created',
payload,
idempotencyKey: 'sub_123',
processingStatus: WebhookProcessingStatus.PENDING,
},
isDuplicate: false,
});
const result = await controller.handleWebhook(req, 'valid-signature', payload);
expect(result).toEqual({ received: true, eventId: 'evt_123' });
expect(webhookEventsService.markAsProcessed).toHaveBeenCalledWith('evt_123');
});
});
describe('Error Handling', () => {
it('should mark event as failed when processing throws error', async () => {
const payload = {
event: 'subscription.created',
data: {
subscriptionId: 'sub_123',
userId: 'user_456',
amount: 29.99,
},
timestamp: Date.now(),
};
const req = { rawBody: Buffer.from(JSON.stringify(payload)) } as RawBodyRequest<Request>;
segpayProvider.verifyWebhookSignature.mockResolvedValue(true);
webhookEventsService.storeWebhookEvent.mockResolvedValue({
event: {
id: 'evt_123',
provider: 'segpay',
eventType: 'subscription.created',
payload,
idempotencyKey: 'sub_123',
processingStatus: WebhookProcessingStatus.PENDING,
},
isDuplicate: false,
});
const processingError = new Error('Database connection failed');
paymentAnalytics.trackRevenue.mockRejectedValue(processingError);
const result = await controller.handleWebhook(req, 'valid-signature', payload);
expect(result).toEqual({ received: true, eventId: 'evt_123' });
expect(webhookEventsService.markAsFailed).toHaveBeenCalledWith('evt_123', processingError);
expect(webhookEventsService.markAsProcessed).not.toHaveBeenCalled();
});
it('should return 200 even when processing fails', async () => {
const payload = {
event: 'payment.succeeded',
data: {
transactionId: 'txn_123',
userId: 'user_456',
amount: 49.99,
},
timestamp: Date.now(),
};
const req = { rawBody: Buffer.from(JSON.stringify(payload)) } as RawBodyRequest<Request>;
segpayProvider.verifyWebhookSignature.mockResolvedValue(true);
webhookEventsService.storeWebhookEvent.mockResolvedValue({
event: {
id: 'evt_456',
provider: 'segpay',
eventType: 'payment.succeeded',
payload,
idempotencyKey: 'txn_123',
processingStatus: WebhookProcessingStatus.PENDING,
},
isDuplicate: false,
});
paymentAnalytics.trackRevenue.mockRejectedValue(new Error('Analytics service down'));
const result = await controller.handleWebhook(req, 'valid-signature', payload);
// Should return success to prevent provider retries
expect(result).toEqual({ received: true, eventId: 'evt_456' });
});
});
describe('Event Routing - subscription.created', () => {
it('should track revenue and emit domain events for subscription.created', async () => {
const payload = {
event: 'subscription.created',
data: {
subscriptionId: 'sub_123',
userId: 'user_456',
amount: 29.99,
currency: 'USD',
tierId: 'tier_gold',
transactionId: 'txn_789',
interval: 'monthly',
platformFee: 2.99,
merchantId: 'merchant_abc',
},
timestamp: Date.now(),
};
const req = { rawBody: Buffer.from(JSON.stringify(payload)) } as RawBodyRequest<Request>;
segpayProvider.verifyWebhookSignature.mockResolvedValue(true);
webhookEventsService.storeWebhookEvent.mockResolvedValue({
event: { id: 'evt_123' },
isDuplicate: false,
});
await controller.handleWebhook(req, 'valid-signature', payload);
// Verify trackRevenue call
expect(paymentAnalytics.trackRevenue).toHaveBeenCalledWith({
userId: 'user_456',
transactionId: 'txn_789',
transactionType: TransactionType.SUBSCRIPTION,
amount: 29.99,
currency: 'USD',
platformFee: 2.99,
metadata: 'subscription.created',
});
// Verify emitSubscriptionCreated call
expect(domainEvents.emitSubscriptionCreated).toHaveBeenCalledWith({
subscriptionId: 'sub_123',
userId: 'user_456',
tierId: 'tier_gold',
transactionId: 'txn_789',
amountInCents: 2999, // 29.99 * 100 rounded
currency: 'USD',
interval: 'monthly',
createdAt: expect.any(String),
merchantId: 'merchant_abc',
metadata: {
provider: 'segpay',
platformFee: 2.99,
},
});
// Verify emitPurchase call (first subscription payment)
expect(domainEvents.emitPurchase).toHaveBeenCalledWith({
sessionId: 'txn_789',
userId: 'user_456',
transactionId: 'txn_789',
amountInCents: 2999,
type: 'subscription',
attribution: expect.any(Object),
});
});
it('should use default values when optional fields are missing', async () => {
const payload = {
event: 'subscription.created',
data: {
subscriptionId: 'sub_123',
userId: 'user_456',
amount: 19.99,
},
timestamp: Date.now(),
};
const req = { rawBody: Buffer.from(JSON.stringify(payload)) } as RawBodyRequest<Request>;
segpayProvider.verifyWebhookSignature.mockResolvedValue(true);
webhookEventsService.storeWebhookEvent.mockResolvedValue({
event: { id: 'evt_123' },
isDuplicate: false,
});
await controller.handleWebhook(req, 'valid-signature', payload);
expect(domainEvents.emitSubscriptionCreated).toHaveBeenCalledWith({
subscriptionId: 'sub_123',
userId: 'user_456',
tierId: 'default',
transactionId: 'sub_123', // Falls back to subscriptionId
amountInCents: 1999,
currency: 'USD', // Default
interval: 'monthly', // Default
createdAt: expect.any(String),
merchantId: undefined,
metadata: {
provider: 'segpay',
platformFee: 0, // Default
},
});
});
});
describe('Event Routing - subscription.cancelled', () => {
it('should emit SubscriptionCancelled domain event', async () => {
const payload = {
event: 'subscription.cancelled',
data: {
subscriptionId: 'sub_123',
userId: 'user_456',
tierId: 'tier_gold',
reason: 'customer_request',
endsAt: '2026-03-05T00:00:00Z',
merchantId: 'merchant_abc',
},
timestamp: Date.now(),
};
const req = { rawBody: Buffer.from(JSON.stringify(payload)) } as RawBodyRequest<Request>;
segpayProvider.verifyWebhookSignature.mockResolvedValue(true);
webhookEventsService.storeWebhookEvent.mockResolvedValue({
event: { id: 'evt_123' },
isDuplicate: false,
});
await controller.handleWebhook(req, 'valid-signature', payload);
expect(domainEvents.emitSubscriptionCancelled).toHaveBeenCalledWith({
subscriptionId: 'sub_123',
userId: 'user_456',
tierId: 'tier_gold',
reason: 'customer_request',
cancelledAt: expect.any(String),
endsAt: '2026-03-05T00:00:00Z',
merchantId: 'merchant_abc',
metadata: {
provider: 'segpay',
},
});
// Should NOT track revenue for cancellations
expect(paymentAnalytics.trackRevenue).not.toHaveBeenCalled();
});
it('should use default values for missing cancellation fields', async () => {
const payload = {
event: 'subscription.cancelled',
data: {
subscriptionId: 'sub_123',
userId: 'user_456',
},
timestamp: Date.now(),
};
const req = { rawBody: Buffer.from(JSON.stringify(payload)) } as RawBodyRequest<Request>;
segpayProvider.verifyWebhookSignature.mockResolvedValue(true);
webhookEventsService.storeWebhookEvent.mockResolvedValue({
event: { id: 'evt_123' },
isDuplicate: false,
});
await controller.handleWebhook(req, 'valid-signature', payload);
expect(domainEvents.emitSubscriptionCancelled).toHaveBeenCalledWith({
subscriptionId: 'sub_123',
userId: 'user_456',
tierId: 'default',
reason: 'customer_request', // Default
cancelledAt: expect.any(String),
endsAt: expect.any(String), // Default: 30 days from now
merchantId: undefined,
metadata: {
provider: 'segpay',
},
});
});
});
describe('Event Routing - subscription.renewed', () => {
it('should track revenue and emit renewal events', async () => {
const payload = {
event: 'subscription.renewed',
data: {
subscriptionId: 'sub_123',
userId: 'user_456',
amount: 29.99,
currency: 'USD',
tierId: 'tier_gold',
transactionId: 'txn_renewal_789',
interval: 'monthly',
renewalCount: 2, // 3rd payment total
platformFee: 2.99,
merchantId: 'merchant_abc',
},
timestamp: Date.now(),
};
const req = { rawBody: Buffer.from(JSON.stringify(payload)) } as RawBodyRequest<Request>;
segpayProvider.verifyWebhookSignature.mockResolvedValue(true);
webhookEventsService.storeWebhookEvent.mockResolvedValue({
event: { id: 'evt_123' },
isDuplicate: false,
});
await controller.handleWebhook(req, 'valid-signature', payload);
// Verify trackRevenue call
expect(paymentAnalytics.trackRevenue).toHaveBeenCalledWith({
userId: 'user_456',
transactionId: 'txn_renewal_789',
transactionType: TransactionType.SUBSCRIPTION,
amount: 29.99,
currency: 'USD',
platformFee: 2.99,
metadata: 'subscription.renewed',
});
// Verify emitSubscriptionRenewed call
expect(domainEvents.emitSubscriptionRenewed).toHaveBeenCalledWith({
subscriptionId: 'sub_123',
userId: 'user_456',
tierId: 'tier_gold',
transactionId: 'txn_renewal_789',
amountInCents: 2999,
currency: 'USD',
interval: 'monthly',
renewalCount: 2,
renewedAt: expect.any(String),
merchantId: 'merchant_abc',
metadata: {
provider: 'segpay',
platformFee: 2.99,
},
});
// Verify emitRepeatPurchase call (renewalCount=2 means 3rd payment)
expect(domainEvents.emitRepeatPurchase).toHaveBeenCalledWith({
sessionId: 'txn_renewal_789',
userId: 'user_456',
transactionId: 'txn_renewal_789',
amountInCents: 2999,
purchaseCount: 3, // renewalCount + 1
attribution: expect.any(Object),
});
});
});
describe('Event Routing - payment.succeeded', () => {
it('should track revenue and emit events for tip payment', async () => {
const payload = {
event: 'payment.succeeded',
data: {
transactionId: 'txn_tip_123',
userId: 'user_456',
amount: 15.50,
currency: 'USD',
paymentType: 'tip',
platformFee: 1.50,
merchantId: 'merchant_abc',
productId: 'prod_tip_001',
},
timestamp: Date.now(),
};
const req = { rawBody: Buffer.from(JSON.stringify(payload)) } as RawBodyRequest<Request>;
segpayProvider.verifyWebhookSignature.mockResolvedValue(true);
webhookEventsService.storeWebhookEvent.mockResolvedValue({
event: { id: 'evt_123' },
isDuplicate: false,
});
await controller.handleWebhook(req, 'valid-signature', payload);
// Verify trackRevenue call with TIP type
expect(paymentAnalytics.trackRevenue).toHaveBeenCalledWith({
userId: 'user_456',
transactionId: 'txn_tip_123',
transactionType: TransactionType.TIP,
amount: 15.50,
currency: 'USD',
platformFee: 1.50,
metadata: 'payment.succeeded.tip',
});
// Verify emitPaymentCompleted call
expect(domainEvents.emitPaymentCompleted).toHaveBeenCalledWith({
transactionId: 'txn_tip_123',
userId: 'user_456',
amountInCents: 1550, // 15.50 * 100
currency: 'USD',
paymentMethod: 'card',
paymentType: 'tip',
completedAt: expect.any(String),
merchantId: 'merchant_abc',
productId: 'prod_tip_001',
metadata: {
provider: 'segpay',
platformFee: 1.50,
},
});
// Verify emitPurchase call with type 'tip'
expect(domainEvents.emitPurchase).toHaveBeenCalledWith({
sessionId: 'txn_tip_123',
userId: 'user_456',
transactionId: 'txn_tip_123',
amountInCents: 1550,
type: 'tip',
attribution: expect.any(Object),
});
});
it('should track revenue and emit events for booking payment', async () => {
const payload = {
event: 'payment.succeeded',
data: {
transactionId: 'txn_booking_456',
userId: 'user_789',
amount: 199.99,
currency: 'EUR',
paymentType: 'booking',
platformFee: 19.99,
merchantId: 'merchant_xyz',
},
timestamp: Date.now(),
};
const req = { rawBody: Buffer.from(JSON.stringify(payload)) } as RawBodyRequest<Request>;
segpayProvider.verifyWebhookSignature.mockResolvedValue(true);
webhookEventsService.storeWebhookEvent.mockResolvedValue({
event: { id: 'evt_456' },
isDuplicate: false,
});
await controller.handleWebhook(req, 'valid-signature', payload);
// Verify trackRevenue call with SERVICEBOOKING type
expect(paymentAnalytics.trackRevenue).toHaveBeenCalledWith({
userId: 'user_789',
transactionId: 'txn_booking_456',
transactionType: TransactionType.SERVICEBOOKING,
amount: 199.99,
currency: 'EUR',
platformFee: 19.99,
metadata: 'payment.succeeded.booking',
});
// Verify emitPaymentCompleted call
expect(domainEvents.emitPaymentCompleted).toHaveBeenCalledWith({
transactionId: 'txn_booking_456',
userId: 'user_789',
amountInCents: 19999,
currency: 'EUR',
paymentMethod: 'card',
paymentType: 'booking',
completedAt: expect.any(String),
merchantId: 'merchant_xyz',
productId: undefined,
metadata: {
provider: 'segpay',
platformFee: 19.99,
},
});
// Verify emitPurchase call with type 'one_time' (booking maps to one_time for funnel)
expect(domainEvents.emitPurchase).toHaveBeenCalledWith({
sessionId: 'txn_booking_456',
userId: 'user_789',
transactionId: 'txn_booking_456',
amountInCents: 19999,
type: 'one_time',
attribution: expect.any(Object),
});
});
it('should track revenue and emit events for default product payment', async () => {
const payload = {
event: 'payment.succeeded',
data: {
transactionId: 'txn_product_789',
userId: 'user_123',
amount: 49.99,
paymentType: 'product',
},
timestamp: Date.now(),
};
const req = { rawBody: Buffer.from(JSON.stringify(payload)) } as RawBodyRequest<Request>;
segpayProvider.verifyWebhookSignature.mockResolvedValue(true);
webhookEventsService.storeWebhookEvent.mockResolvedValue({
event: { id: 'evt_789' },
isDuplicate: false,
});
await controller.handleWebhook(req, 'valid-signature', payload);
// Verify trackRevenue call with PRODUCTSALE type (default)
expect(paymentAnalytics.trackRevenue).toHaveBeenCalledWith({
userId: 'user_123',
transactionId: 'txn_product_789',
transactionType: TransactionType.PRODUCTSALE,
amount: 49.99,
currency: 'USD', // Default
platformFee: 0, // Default
metadata: 'payment.succeeded.product',
});
// Verify emitPaymentCompleted call
expect(domainEvents.emitPaymentCompleted).toHaveBeenCalledWith({
transactionId: 'txn_product_789',
userId: 'user_123',
amountInCents: 4999,
currency: 'USD',
paymentMethod: 'card',
paymentType: 'one_time',
completedAt: expect.any(String),
merchantId: undefined,
productId: undefined,
metadata: {
provider: 'segpay',
platformFee: 0,
},
});
});
it('should handle payment without paymentType field', async () => {
const payload = {
event: 'payment.succeeded',
data: {
transactionId: 'txn_unknown_999',
userId: 'user_999',
amount: 99.99,
},
timestamp: Date.now(),
};
const req = { rawBody: Buffer.from(JSON.stringify(payload)) } as RawBodyRequest<Request>;
segpayProvider.verifyWebhookSignature.mockResolvedValue(true);
webhookEventsService.storeWebhookEvent.mockResolvedValue({
event: { id: 'evt_999' },
isDuplicate: false,
});
await controller.handleWebhook(req, 'valid-signature', payload);
// Should default to PRODUCTSALE
expect(paymentAnalytics.trackRevenue).toHaveBeenCalledWith(
expect.objectContaining({
transactionType: TransactionType.PRODUCTSALE,
}),
);
// Should map to 'one_time' for PaymentCompleted
expect(domainEvents.emitPaymentCompleted).toHaveBeenCalledWith(
expect.objectContaining({
paymentType: 'one_time',
}),
);
});
});
describe('Event Routing - payment.failed', () => {
it('should only log payment.failed events', async () => {
const payload = {
event: 'payment.failed',
data: {
transactionId: 'txn_failed_123',
failureReason: 'insufficient_funds',
},
timestamp: Date.now(),
};
const req = { rawBody: Buffer.from(JSON.stringify(payload)) } as RawBodyRequest<Request>;
segpayProvider.verifyWebhookSignature.mockResolvedValue(true);
webhookEventsService.storeWebhookEvent.mockResolvedValue({
event: { id: 'evt_fail_123' },
isDuplicate: false,
});
await controller.handleWebhook(req, 'valid-signature', payload);
// Should NOT track revenue or emit events for failed payments
expect(paymentAnalytics.trackRevenue).not.toHaveBeenCalled();
expect(domainEvents.emitPaymentCompleted).not.toHaveBeenCalled();
expect(domainEvents.emitPurchase).not.toHaveBeenCalled();
expect(webhookEventsService.markAsProcessed).toHaveBeenCalledWith('evt_fail_123');
});
});
describe('Event Routing - gift_card.purchased', () => {
it('should track gift card purchase as product sale', async () => {
const payload = {
event: 'gift_card.purchased',
data: {
giftCardId: 'gc_123',
purchaserId: 'user_buyer_456',
amount: 50.00,
currency: 'USD',
platformFee: 5.00,
},
timestamp: Date.now(),
};
const req = { rawBody: Buffer.from(JSON.stringify(payload)) } as RawBodyRequest<Request>;
segpayProvider.verifyWebhookSignature.mockResolvedValue(true);
webhookEventsService.storeWebhookEvent.mockResolvedValue({
event: { id: 'evt_gc_123' },
isDuplicate: false,
});
await controller.handleWebhook(req, 'valid-signature', payload);
// Verify trackRevenue call
expect(paymentAnalytics.trackRevenue).toHaveBeenCalledWith({
userId: 'user_buyer_456',
transactionId: 'gc_123',
transactionType: TransactionType.PRODUCTSALE,
amount: 50.00,
currency: 'USD',
platformFee: 5.00,
metadata: 'gift_card.purchased',
});
// Should NOT emit domain events (gift cards have separate flow)
expect(domainEvents.emitPaymentCompleted).not.toHaveBeenCalled();
expect(domainEvents.emitPurchase).not.toHaveBeenCalled();
});
it('should use generated transactionId when giftCardId is missing', async () => {
const payload = {
event: 'gift_card.purchased',
data: {
purchaserId: 'user_buyer_789',
amount: 100.00,
},
timestamp: Date.now(),
};
const req = { rawBody: Buffer.from(JSON.stringify(payload)) } as RawBodyRequest<Request>;
segpayProvider.verifyWebhookSignature.mockResolvedValue(true);
webhookEventsService.storeWebhookEvent.mockResolvedValue({
event: { id: 'evt_gc_456' },
isDuplicate: false,
});
await controller.handleWebhook(req, 'valid-signature', payload);
// Should generate transactionId
expect(paymentAnalytics.trackRevenue).toHaveBeenCalledWith(
expect.objectContaining({
userId: 'user_buyer_789',
transactionId: expect.stringMatching(/^gc_\d+$/),
amount: 100.00,
}),
);
});
});
describe('Event Routing - chargeback.lost', () => {
it('should track refund and emit PaymentRefunded event', async () => {
const payload = {
event: 'chargeback.lost',
data: {
chargebackId: 'cb_123',
userId: 'user_456',
originalTransactionId: 'txn_original_789',
amount: 29.99,
currency: 'USD',
platformFee: 2.99,
merchantId: 'merchant_abc',
},
timestamp: Date.now(),
};
const req = { rawBody: Buffer.from(JSON.stringify(payload)) } as RawBodyRequest<Request>;
segpayProvider.verifyWebhookSignature.mockResolvedValue(true);
webhookEventsService.storeWebhookEvent.mockResolvedValue({
event: { id: 'evt_cb_123' },
isDuplicate: false,
});
await controller.handleWebhook(req, 'valid-signature', payload);
// Verify trackRefund call
expect(paymentAnalytics.trackRefund).toHaveBeenCalledWith({
userId: 'user_456',
transactionId: 'cb_123',
amount: 29.99,
currency: 'USD',
platformFee: 2.99,
metadata: 'chargeback.lost',
});
// Verify emitPaymentRefunded call
expect(domainEvents.emitPaymentRefunded).toHaveBeenCalledWith({
transactionId: 'txn_original_789',
refundId: 'cb_123',
userId: 'user_456',
amountInCents: 2999,
currency: 'USD',
reason: 'chargeback',
refundType: 'full',
refundedAt: expect.any(String),
merchantId: 'merchant_abc',
metadata: {
provider: 'segpay',
chargebackId: 'cb_123',
},
});
});
it('should fallback to transactionId when originalTransactionId is missing', async () => {
const payload = {
event: 'chargeback.lost',
data: {
chargebackId: 'cb_456',
userId: 'user_789',
transactionId: 'txn_fallback_123',
amount: 49.99,
},
timestamp: Date.now(),
};
const req = { rawBody: Buffer.from(JSON.stringify(payload)) } as RawBodyRequest<Request>;
segpayProvider.verifyWebhookSignature.mockResolvedValue(true);
webhookEventsService.storeWebhookEvent.mockResolvedValue({
event: { id: 'evt_cb_456' },
isDuplicate: false,
});
await controller.handleWebhook(req, 'valid-signature', payload);
// Verify emitPaymentRefunded uses transactionId fallback
expect(domainEvents.emitPaymentRefunded).toHaveBeenCalledWith(
expect.objectContaining({
transactionId: 'txn_fallback_123',
refundId: 'cb_456',
}),
);
});
it('should generate refundId when chargebackId is missing', async () => {
const payload = {
event: 'chargeback.lost',
data: {
userId: 'user_999',
transactionId: 'txn_999',
amount: 99.99,
},
timestamp: Date.now(),
};
const req = { rawBody: Buffer.from(JSON.stringify(payload)) } as RawBodyRequest<Request>;
segpayProvider.verifyWebhookSignature.mockResolvedValue(true);
webhookEventsService.storeWebhookEvent.mockResolvedValue({
event: { id: 'evt_cb_999' },
isDuplicate: false,
});
await controller.handleWebhook(req, 'valid-signature', payload);
// Should generate refundId with timestamp
expect(paymentAnalytics.trackRefund).toHaveBeenCalledWith(
expect.objectContaining({
transactionId: expect.stringMatching(/^chargeback_\d+$/),
}),
);
expect(domainEvents.emitPaymentRefunded).toHaveBeenCalledWith(
expect.objectContaining({
refundId: expect.stringMatching(/^chargeback_\d+$/),
}),
);
});
});
describe('Event Routing - chargeback.created and chargeback.won', () => {
it('should flag transaction and emit refund event for chargeback.created', async () => {
const payload = {
event: 'chargeback.created',
data: {
chargebackId: 'cb_new_123',
transactionId: 'txn_disputed_456',
amount: 75.00,
},
timestamp: Date.now(),
};
const req = { rawBody: Buffer.from(JSON.stringify(payload)) } as RawBodyRequest<Request>;
segpayProvider.verifyWebhookSignature.mockResolvedValue(true);
webhookEventsService.storeWebhookEvent.mockResolvedValue({
event: { id: 'evt_cb_created' },
isDuplicate: false,
});
await controller.handleWebhook(req, 'valid-signature', payload);
// chargeback.created emits refund event (for admin notification) but does NOT track refund revenue
expect(paymentAnalytics.trackRefund).not.toHaveBeenCalled();
expect(domainEvents.emitPaymentRefunded).toHaveBeenCalledWith(
expect.objectContaining({
refundId: 'cb_new_123',
reason: 'chargeback',
}),
);
expect(webhookEventsService.markAsProcessed).toHaveBeenCalledWith('evt_cb_created');
});
it('should only update metadata for chargeback.won events', async () => {
const payload = {
event: 'chargeback.won',
data: {
chargebackId: 'cb_won_789',
},
timestamp: Date.now(),
};
const req = { rawBody: Buffer.from(JSON.stringify(payload)) } as RawBodyRequest<Request>;
segpayProvider.verifyWebhookSignature.mockResolvedValue(true);
webhookEventsService.storeWebhookEvent.mockResolvedValue({
event: { id: 'evt_cb_won' },
isDuplicate: false,
});
await controller.handleWebhook(req, 'valid-signature', payload);
// chargeback.won does NOT track refund or emit events — it only logs + updates metadata
expect(paymentAnalytics.trackRefund).not.toHaveBeenCalled();
expect(domainEvents.emitPaymentRefunded).not.toHaveBeenCalled();
expect(webhookEventsService.markAsProcessed).toHaveBeenCalledWith('evt_cb_won');
});
});
describe('Event Routing - unknown event types', () => {
it('should log warning for unknown event types', async () => {
const payload = {
event: 'unknown.event.type',
data: {
someField: 'someValue',
},
timestamp: Date.now(),
};
const req = { rawBody: Buffer.from(JSON.stringify(payload)) } as RawBodyRequest<Request>;
segpayProvider.verifyWebhookSignature.mockResolvedValue(true);
webhookEventsService.storeWebhookEvent.mockResolvedValue({
event: { id: 'evt_unknown' },
isDuplicate: false,
});
await controller.handleWebhook(req, 'valid-signature', payload);
// Should complete successfully without processing
expect(webhookEventsService.markAsProcessed).toHaveBeenCalledWith('evt_unknown');
// Should NOT call any analytics or domain events
expect(paymentAnalytics.trackRevenue).not.toHaveBeenCalled();
expect(paymentAnalytics.trackRefund).not.toHaveBeenCalled();
expect(domainEvents.emitSubscriptionCreated).not.toHaveBeenCalled();
expect(domainEvents.emitPaymentCompleted).not.toHaveBeenCalled();
});
});
describe('Amount Calculation', () => {
it('should correctly calculate amountInCents with rounding', async () => {
const testCases = [
{ amount: 29.99, expected: 2999 },
{ amount: 0.01, expected: 1 },
{ amount: 100.00, expected: 10000 },
{ amount: 15.555, expected: 1556 }, // Rounds 1555.5 to 1556
{ amount: 15.554, expected: 1555 }, // Rounds 1555.4 to 1555
];
for (const { amount, expected } of testCases) {
const payload = {
event: 'subscription.created',
data: {
subscriptionId: 'sub_test',
userId: 'user_test',
amount,
transactionId: 'txn_test',
},
timestamp: Date.now(),
};
const req = { rawBody: Buffer.from(JSON.stringify(payload)) } as RawBodyRequest<Request>;
segpayProvider.verifyWebhookSignature.mockResolvedValue(true);
webhookEventsService.storeWebhookEvent.mockResolvedValue({
event: { id: 'evt_test' },
isDuplicate: false,
});
await controller.handleWebhook(req, 'valid-signature', payload);
expect(domainEvents.emitSubscriptionCreated).toHaveBeenCalledWith(
expect.objectContaining({
amountInCents: expected,
}),
);
// Reset mocks for next iteration
vi.clearAllMocks();
}
});
});
});