/** * Integration Test: Sponsor Billing Use Case Orchestration * * Tests the orchestration logic of sponsor billing-related Use Cases: * - GetSponsorBillingUseCase: Retrieves sponsor billing information * - Validates that Use Cases correctly interact with their Ports (Repositories) * - Uses In-Memory adapters for fast, deterministic testing * * Focus: Business logic orchestration, NOT UI rendering */ import { describe, it, expect, beforeAll, beforeEach } from 'vitest'; import { InMemorySponsorRepository } from '../../../adapters/racing/persistence/inmemory/InMemorySponsorRepository'; import { InMemorySeasonSponsorshipRepository } from '../../../adapters/racing/persistence/inmemory/InMemorySeasonSponsorshipRepository'; import { InMemoryPaymentRepository } from '../../../adapters/payments/persistence/inmemory/InMemoryPaymentRepository'; import { GetSponsorBillingUseCase } from '../../../core/payments/application/use-cases/GetSponsorBillingUseCase'; import { Sponsor } from '../../../core/racing/domain/entities/sponsor/Sponsor'; import { SeasonSponsorship } from '../../../core/racing/domain/entities/season/SeasonSponsorship'; import { Payment, PaymentType, PaymentStatus } from '../../../core/payments/domain/entities/Payment'; import { Money } from '../../../core/racing/domain/value-objects/Money'; import { Logger } from '../../../core/shared/domain/Logger'; describe('Sponsor Billing Use Case Orchestration', () => { let sponsorRepository: InMemorySponsorRepository; let seasonSponsorshipRepository: InMemorySeasonSponsorshipRepository; let paymentRepository: InMemoryPaymentRepository; let getSponsorBillingUseCase: GetSponsorBillingUseCase; let mockLogger: Logger; beforeAll(() => { mockLogger = { info: () => {}, debug: () => {}, warn: () => {}, error: () => {}, } as unknown as Logger; sponsorRepository = new InMemorySponsorRepository(mockLogger); seasonSponsorshipRepository = new InMemorySeasonSponsorshipRepository(mockLogger); paymentRepository = new InMemoryPaymentRepository(mockLogger); getSponsorBillingUseCase = new GetSponsorBillingUseCase( paymentRepository, seasonSponsorshipRepository, ); }); beforeEach(() => { sponsorRepository.clear(); seasonSponsorshipRepository.clear(); paymentRepository.clear(); }); describe('GetSponsorBillingUseCase - Success Path', () => { it('should retrieve billing statistics for a sponsor with paid invoices', async () => { // Given: A sponsor exists with ID "sponsor-123" const sponsor = Sponsor.create({ id: 'sponsor-123', name: 'Test Company', contactEmail: 'test@example.com', }); await sponsorRepository.create(sponsor); // And: The sponsor has 2 active sponsorships const sponsorship1 = SeasonSponsorship.create({ id: 'sponsorship-1', sponsorId: 'sponsor-123', seasonId: 'season-1', tier: 'main', pricing: Money.create(1000, 'USD'), status: 'active', }); await seasonSponsorshipRepository.create(sponsorship1); const sponsorship2 = SeasonSponsorship.create({ id: 'sponsorship-2', sponsorId: 'sponsor-123', seasonId: 'season-2', tier: 'secondary', pricing: Money.create(500, 'USD'), status: 'active', }); await seasonSponsorshipRepository.create(sponsorship2); // And: The sponsor has 3 paid invoices const payment1: Payment = { id: 'payment-1', type: PaymentType.SPONSORSHIP, amount: 1000, platformFee: 100, netAmount: 900, payerId: 'sponsor-123', payerType: 'sponsor', leagueId: 'league-1', seasonId: 'season-1', status: PaymentStatus.COMPLETED, createdAt: new Date('2025-01-15'), completedAt: new Date('2025-01-15'), }; await paymentRepository.create(payment1); const payment2: Payment = { id: 'payment-2', type: PaymentType.SPONSORSHIP, amount: 2000, platformFee: 200, netAmount: 1800, payerId: 'sponsor-123', payerType: 'sponsor', leagueId: 'league-2', seasonId: 'season-2', status: PaymentStatus.COMPLETED, createdAt: new Date('2025-02-15'), completedAt: new Date('2025-02-15'), }; await paymentRepository.create(payment2); const payment3: Payment = { id: 'payment-3', type: PaymentType.SPONSORSHIP, amount: 3000, platformFee: 300, netAmount: 2700, payerId: 'sponsor-123', payerType: 'sponsor', leagueId: 'league-3', seasonId: 'season-3', status: PaymentStatus.COMPLETED, createdAt: new Date('2025-03-15'), completedAt: new Date('2025-03-15'), }; await paymentRepository.create(payment3); // When: GetSponsorBillingUseCase.execute() is called with sponsor ID const result = await getSponsorBillingUseCase.execute({ sponsorId: 'sponsor-123' }); // Then: The result should contain billing data expect(result.isOk()).toBe(true); const billing = result.unwrap(); // And: The invoices should contain all 3 paid invoices expect(billing.invoices).toHaveLength(3); expect(billing.invoices[0].status).toBe('paid'); expect(billing.invoices[1].status).toBe('paid'); expect(billing.invoices[2].status).toBe('paid'); // And: The stats should show correct total spent // Total spent = 1000 + 2000 + 3000 = 6000 expect(billing.stats.totalSpent).toBe(6000); // And: The stats should show no pending payments expect(billing.stats.pendingAmount).toBe(0); // And: The stats should show no next payment date expect(billing.stats.nextPaymentDate).toBeNull(); expect(billing.stats.nextPaymentAmount).toBeNull(); // And: The stats should show correct active sponsorships expect(billing.stats.activeSponsorships).toBe(2); // And: The stats should show correct average monthly spend // Average monthly spend = total / months = 6000 / 3 = 2000 expect(billing.stats.averageMonthlySpend).toBe(2000); }); it('should retrieve billing statistics with pending invoices', async () => { // Given: A sponsor exists with ID "sponsor-123" const sponsor = Sponsor.create({ id: 'sponsor-123', name: 'Test Company', contactEmail: 'test@example.com', }); await sponsorRepository.create(sponsor); // And: The sponsor has 1 active sponsorship const sponsorship = SeasonSponsorship.create({ id: 'sponsorship-1', sponsorId: 'sponsor-123', seasonId: 'season-1', tier: 'main', pricing: Money.create(1000, 'USD'), status: 'active', }); await seasonSponsorshipRepository.create(sponsorship); // And: The sponsor has 1 paid invoice and 1 pending invoice const payment1: Payment = { id: 'payment-1', type: PaymentType.SPONSORSHIP, amount: 1000, platformFee: 100, netAmount: 900, payerId: 'sponsor-123', payerType: 'sponsor', leagueId: 'league-1', seasonId: 'season-1', status: PaymentStatus.COMPLETED, createdAt: new Date('2025-01-15'), completedAt: new Date('2025-01-15'), }; await paymentRepository.create(payment1); const payment2: Payment = { id: 'payment-2', type: PaymentType.SPONSORSHIP, amount: 500, platformFee: 50, netAmount: 450, payerId: 'sponsor-123', payerType: 'sponsor', leagueId: 'league-2', seasonId: 'season-2', status: PaymentStatus.PENDING, createdAt: new Date('2025-02-15'), }; await paymentRepository.create(payment2); // When: GetSponsorBillingUseCase.execute() is called with sponsor ID const result = await getSponsorBillingUseCase.execute({ sponsorId: 'sponsor-123' }); // Then: The result should contain billing data expect(result.isOk()).toBe(true); const billing = result.unwrap(); // And: The invoices should contain both invoices expect(billing.invoices).toHaveLength(2); // And: The stats should show correct total spent (only paid invoices) expect(billing.stats.totalSpent).toBe(1000); // And: The stats should show correct pending amount expect(billing.stats.pendingAmount).toBe(550); // 500 + 50 // And: The stats should show next payment date expect(billing.stats.nextPaymentDate).toBeDefined(); expect(billing.stats.nextPaymentAmount).toBe(550); // And: The stats should show correct active sponsorships expect(billing.stats.activeSponsorships).toBe(1); }); it('should retrieve billing statistics with zero values when no invoices exist', async () => { // Given: A sponsor exists with ID "sponsor-123" const sponsor = Sponsor.create({ id: 'sponsor-123', name: 'Test Company', contactEmail: 'test@example.com', }); await sponsorRepository.create(sponsor); // And: The sponsor has 1 active sponsorship const sponsorship = SeasonSponsorship.create({ id: 'sponsorship-1', sponsorId: 'sponsor-123', seasonId: 'season-1', tier: 'main', pricing: Money.create(1000, 'USD'), status: 'active', }); await seasonSponsorshipRepository.create(sponsorship); // And: The sponsor has no invoices // When: GetSponsorBillingUseCase.execute() is called with sponsor ID const result = await getSponsorBillingUseCase.execute({ sponsorId: 'sponsor-123' }); // Then: The result should contain billing data expect(result.isOk()).toBe(true); const billing = result.unwrap(); // And: The invoices should be empty expect(billing.invoices).toHaveLength(0); // And: The stats should show zero values expect(billing.stats.totalSpent).toBe(0); expect(billing.stats.pendingAmount).toBe(0); expect(billing.stats.nextPaymentDate).toBeNull(); expect(billing.stats.nextPaymentAmount).toBeNull(); expect(billing.stats.activeSponsorships).toBe(1); expect(billing.stats.averageMonthlySpend).toBe(0); }); it('should retrieve billing statistics with mixed invoice statuses', async () => { // Given: A sponsor exists with ID "sponsor-123" const sponsor = Sponsor.create({ id: 'sponsor-123', name: 'Test Company', contactEmail: 'test@example.com', }); await sponsorRepository.create(sponsor); // And: The sponsor has 1 active sponsorship const sponsorship = SeasonSponsorship.create({ id: 'sponsorship-1', sponsorId: 'sponsor-123', seasonId: 'season-1', tier: 'main', pricing: Money.create(1000, 'USD'), status: 'active', }); await seasonSponsorshipRepository.create(sponsorship); // And: The sponsor has invoices with different statuses const payment1: Payment = { id: 'payment-1', type: PaymentType.SPONSORSHIP, amount: 1000, platformFee: 100, netAmount: 900, payerId: 'sponsor-123', payerType: 'sponsor', leagueId: 'league-1', seasonId: 'season-1', status: PaymentStatus.COMPLETED, createdAt: new Date('2025-01-15'), completedAt: new Date('2025-01-15'), }; await paymentRepository.create(payment1); const payment2: Payment = { id: 'payment-2', type: PaymentType.SPONSORSHIP, amount: 500, platformFee: 50, netAmount: 450, payerId: 'sponsor-123', payerType: 'sponsor', leagueId: 'league-2', seasonId: 'season-2', status: PaymentStatus.PENDING, createdAt: new Date('2025-02-15'), }; await paymentRepository.create(payment2); const payment3: Payment = { id: 'payment-3', type: PaymentType.SPONSORSHIP, amount: 300, platformFee: 30, netAmount: 270, payerId: 'sponsor-123', payerType: 'sponsor', leagueId: 'league-3', seasonId: 'season-3', status: PaymentStatus.FAILED, createdAt: new Date('2025-03-15'), }; await paymentRepository.create(payment3); // When: GetSponsorBillingUseCase.execute() is called with sponsor ID const result = await getSponsorBillingUseCase.execute({ sponsorId: 'sponsor-123' }); // Then: The result should contain billing data expect(result.isOk()).toBe(true); const billing = result.unwrap(); // And: The invoices should contain all 3 invoices expect(billing.invoices).toHaveLength(3); // And: The stats should show correct total spent (only paid invoices) expect(billing.stats.totalSpent).toBe(1000); // And: The stats should show correct pending amount (pending + failed) expect(billing.stats.pendingAmount).toBe(550); // 500 + 50 // And: The stats should show correct active sponsorships expect(billing.stats.activeSponsorships).toBe(1); }); }); describe('GetSponsorBillingUseCase - Error Handling', () => { it('should return error when sponsor does not exist', async () => { // Given: No sponsor exists with the given ID // When: GetSponsorBillingUseCase.execute() is called with non-existent sponsor ID const result = await getSponsorBillingUseCase.execute({ sponsorId: 'non-existent-sponsor' }); // Then: Should return an error expect(result.isErr()).toBe(true); const error = result.unwrapErr(); expect(error.code).toBe('SPONSOR_NOT_FOUND'); }); }); describe('Sponsor Billing Data Orchestration', () => { it('should correctly aggregate billing statistics across multiple invoices', async () => { // Given: A sponsor exists with ID "sponsor-123" const sponsor = Sponsor.create({ id: 'sponsor-123', name: 'Test Company', contactEmail: 'test@example.com', }); await sponsorRepository.create(sponsor); // And: The sponsor has 1 active sponsorship const sponsorship = SeasonSponsorship.create({ id: 'sponsorship-1', sponsorId: 'sponsor-123', seasonId: 'season-1', tier: 'main', pricing: Money.create(1000, 'USD'), status: 'active', }); await seasonSponsorshipRepository.create(sponsorship); // And: The sponsor has 5 invoices with different amounts and statuses const invoices = [ { id: 'payment-1', amount: 1000, status: PaymentStatus.COMPLETED, date: new Date('2025-01-15') }, { id: 'payment-2', amount: 2000, status: PaymentStatus.COMPLETED, date: new Date('2025-02-15') }, { id: 'payment-3', amount: 1500, status: PaymentStatus.PENDING, date: new Date('2025-03-15') }, { id: 'payment-4', amount: 3000, status: PaymentStatus.COMPLETED, date: new Date('2025-04-15') }, { id: 'payment-5', amount: 500, status: PaymentStatus.FAILED, date: new Date('2025-05-15') }, ]; for (const invoice of invoices) { const payment: Payment = { id: invoice.id, type: PaymentType.SPONSORSHIP, amount: invoice.amount, platformFee: invoice.amount * 0.1, netAmount: invoice.amount * 0.9, payerId: 'sponsor-123', payerType: 'sponsor', leagueId: 'league-1', seasonId: 'season-1', status: invoice.status, createdAt: invoice.date, completedAt: invoice.status === PaymentStatus.COMPLETED ? invoice.date : undefined, }; await paymentRepository.create(payment); } // When: GetSponsorBillingUseCase.execute() is called const result = await getSponsorBillingUseCase.execute({ sponsorId: 'sponsor-123' }); // Then: The billing statistics should be correctly aggregated expect(result.isOk()).toBe(true); const billing = result.unwrap(); // Total spent = 1000 + 2000 + 3000 = 6000 expect(billing.stats.totalSpent).toBe(6000); // Pending amount = 1500 + 500 = 2000 expect(billing.stats.pendingAmount).toBe(2000); // Average monthly spend = 6000 / 5 = 1200 expect(billing.stats.averageMonthlySpend).toBe(1200); // Active sponsorships = 1 expect(billing.stats.activeSponsorships).toBe(1); }); it('should correctly calculate average monthly spend over time', async () => { // Given: A sponsor exists with ID "sponsor-123" const sponsor = Sponsor.create({ id: 'sponsor-123', name: 'Test Company', contactEmail: 'test@example.com', }); await sponsorRepository.create(sponsor); // And: The sponsor has 1 active sponsorship const sponsorship = SeasonSponsorship.create({ id: 'sponsorship-1', sponsorId: 'sponsor-123', seasonId: 'season-1', tier: 'main', pricing: Money.create(1000, 'USD'), status: 'active', }); await seasonSponsorshipRepository.create(sponsorship); // And: The sponsor has invoices spanning 6 months const invoices = [ { id: 'payment-1', amount: 1000, date: new Date('2025-01-15') }, { id: 'payment-2', amount: 1500, date: new Date('2025-02-15') }, { id: 'payment-3', amount: 2000, date: new Date('2025-03-15') }, { id: 'payment-4', amount: 2500, date: new Date('2025-04-15') }, { id: 'payment-5', amount: 3000, date: new Date('2025-05-15') }, { id: 'payment-6', amount: 3500, date: new Date('2025-06-15') }, ]; for (const invoice of invoices) { const payment: Payment = { id: invoice.id, type: PaymentType.SPONSORSHIP, amount: invoice.amount, platformFee: invoice.amount * 0.1, netAmount: invoice.amount * 0.9, payerId: 'sponsor-123', payerType: 'sponsor', leagueId: 'league-1', seasonId: 'season-1', status: PaymentStatus.COMPLETED, createdAt: invoice.date, completedAt: invoice.date, }; await paymentRepository.create(payment); } // When: GetSponsorBillingUseCase.execute() is called const result = await getSponsorBillingUseCase.execute({ sponsorId: 'sponsor-123' }); // Then: The average monthly spend should be calculated correctly expect(result.isOk()).toBe(true); const billing = result.unwrap(); // Total = 1000 + 1500 + 2000 + 2500 + 3000 + 3500 = 13500 // Months = 6 (Jan to Jun) // Average = 13500 / 6 = 2250 expect(billing.stats.averageMonthlySpend).toBe(2250); }); it('should correctly identify next payment date from pending invoices', async () => { // Given: A sponsor exists with ID "sponsor-123" const sponsor = Sponsor.create({ id: 'sponsor-123', name: 'Test Company', contactEmail: 'test@example.com', }); await sponsorRepository.create(sponsor); // And: The sponsor has 1 active sponsorship const sponsorship = SeasonSponsorship.create({ id: 'sponsorship-1', sponsorId: 'sponsor-123', seasonId: 'season-1', tier: 'main', pricing: Money.create(1000, 'USD'), status: 'active', }); await seasonSponsorshipRepository.create(sponsorship); // And: The sponsor has multiple pending invoices with different due dates const invoices = [ { id: 'payment-1', amount: 500, date: new Date('2025-03-15') }, { id: 'payment-2', amount: 1000, date: new Date('2025-02-15') }, { id: 'payment-3', amount: 750, date: new Date('2025-01-15') }, ]; for (const invoice of invoices) { const payment: Payment = { id: invoice.id, type: PaymentType.SPONSORSHIP, amount: invoice.amount, platformFee: invoice.amount * 0.1, netAmount: invoice.amount * 0.9, payerId: 'sponsor-123', payerType: 'sponsor', leagueId: 'league-1', seasonId: 'season-1', status: PaymentStatus.PENDING, createdAt: invoice.date, }; await paymentRepository.create(payment); } // When: GetSponsorBillingUseCase.execute() is called const result = await getSponsorBillingUseCase.execute({ sponsorId: 'sponsor-123' }); // Then: The next payment should be the earliest pending invoice expect(result.isOk()).toBe(true); const billing = result.unwrap(); // Next payment should be from payment-3 (earliest date) expect(billing.stats.nextPaymentDate).toBe('2025-01-15T00:00:00.000Z'); expect(billing.stats.nextPaymentAmount).toBe(825); // 750 + 75 }); }); });