This commit is contained in:
2025-12-21 19:53:22 +01:00
parent f2d8a23583
commit 3c64f328e2
105 changed files with 3191 additions and 1706 deletions

View File

@@ -17,8 +17,7 @@ import { ISponsorshipPricingRepository } from '@core/racing/domain/repositories/
import { ISponsorshipRequestRepository } from '@core/racing/domain/repositories/ISponsorshipRequestRepository';
import type { Logger } from '@core/shared/application';
// Import use cases / application services
import { SponsorBillingService } from '@core/payments/application/services/SponsorBillingService';
import { GetSponsorBillingUseCase } from '@core/payments/application/use-cases/GetSponsorBillingUseCase';
import { AcceptSponsorshipRequestUseCase } from '@core/racing/application/use-cases/AcceptSponsorshipRequestUseCase';
import { CreateSponsorUseCase } from '@core/racing/application/use-cases/CreateSponsorUseCase';
import { GetEntitySponsorshipPricingUseCase } from '@core/racing/application/use-cases/GetEntitySponsorshipPricingUseCase';
@@ -63,7 +62,7 @@ export const GET_SPONSOR_USE_CASE_TOKEN = 'GetSponsorUseCase';
export const GET_PENDING_SPONSORSHIP_REQUESTS_USE_CASE_TOKEN = 'GetPendingSponsorshipRequestsUseCase';
export const ACCEPT_SPONSORSHIP_REQUEST_USE_CASE_TOKEN = 'AcceptSponsorshipRequestUseCase';
export const REJECT_SPONSORSHIP_REQUEST_USE_CASE_TOKEN = 'RejectSponsorshipRequestUseCase';
export const SPONSOR_BILLING_SERVICE_TOKEN = 'SponsorBillingService';
export const GET_SPONSOR_BILLING_USE_CASE_TOKEN = 'GetSponsorBillingUseCase';
export const SponsorProviders: Provider[] = [
SponsorService,
@@ -154,9 +153,9 @@ export const SponsorProviders: Provider[] = [
],
},
{
provide: SPONSOR_BILLING_SERVICE_TOKEN,
provide: GET_SPONSOR_BILLING_USE_CASE_TOKEN,
useFactory: (paymentRepo: IPaymentRepository, seasonSponsorshipRepo: ISeasonSponsorshipRepository) =>
new SponsorBillingService(paymentRepo, seasonSponsorshipRepo),
new GetSponsorBillingUseCase(paymentRepo, seasonSponsorshipRepo),
inject: ['IPaymentRepository', SEASON_SPONSORSHIP_REPOSITORY_TOKEN],
},
{

View File

@@ -28,6 +28,8 @@ import {
} from '@core/racing/application/use-cases/GetPendingSponsorshipRequestsUseCase';
import { AcceptSponsorshipRequestUseCase } from '@core/racing/application/use-cases/AcceptSponsorshipRequestUseCase';
import { RejectSponsorshipRequestUseCase } from '@core/racing/application/use-cases/RejectSponsorshipRequestUseCase';
import { GetSponsorBillingUseCase } from '@core/payments/application/use-cases/GetSponsorBillingUseCase';
import { GET_SPONSOR_BILLING_USE_CASE_TOKEN } from './SponsorProviders';
import type { SponsorableEntityType } from '@core/racing/domain/entities/SponsorshipRequest';
import type { Logger } from '@core/shared/application';
@@ -82,6 +84,8 @@ export class SponsorService {
private readonly acceptSponsorshipRequestUseCase: AcceptSponsorshipRequestUseCase,
@Inject(REJECT_SPONSORSHIP_REQUEST_USE_CASE_TOKEN)
private readonly rejectSponsorshipRequestUseCase: RejectSponsorshipRequestUseCase,
@Inject(GET_SPONSOR_BILLING_USE_CASE_TOKEN)
private readonly getSponsorBillingUseCase: GetSponsorBillingUseCase,
@Inject(LOGGER_TOKEN)
private readonly logger: Logger,
) {}
@@ -102,20 +106,15 @@ export class SponsorService {
return presenter;
}
async getSponsors(): Promise<GetSponsorsPresenter> {
async getSponsors(): Promise<GetSponsorsOutputDTO> {
this.logger.debug('[SponsorService] Fetching sponsors.');
const presenter = new GetSponsorsPresenter();
const result = await this.getSponsorsUseCase.execute();
if (result.isErr()) {
this.logger.error('[SponsorService] Failed to fetch sponsors.', result.error);
presenter.present({ sponsors: [] });
return presenter;
}
presenter.present(result);
presenter.present(result.value);
return presenter;
return presenter.responseModel;
}
async createSponsor(input: CreateSponsorInputDTO): Promise<CreateSponsorPresenter> {
@@ -264,92 +263,18 @@ export class SponsorService {
return presenter;
}
async getSponsorBilling(sponsorId: string): Promise<SponsorBillingPresenter> {
async getSponsorBilling(sponsorId: string): Promise<GetSponsorBillingPresenter> {
this.logger.debug('[SponsorService] Fetching sponsor billing.', { sponsorId });
const presenter = new SponsorBillingPresenter();
const result = await this.getSponsorBillingUseCase.execute({ sponsorId });
// Mock data - in real implementation, this would come from repositories
const paymentMethods: PaymentMethodDTO[] = [
{
id: 'pm-1',
type: 'card',
last4: '4242',
brand: 'Visa',
isDefault: true,
expiryMonth: 12,
expiryYear: 2027,
},
{
id: 'pm-2',
type: 'card',
last4: '5555',
brand: 'Mastercard',
isDefault: false,
expiryMonth: 6,
expiryYear: 2026,
},
{
id: 'pm-3',
type: 'sepa',
last4: '8901',
bankName: 'Deutsche Bank',
isDefault: false,
},
];
if (result.isErr()) {
this.logger.error('[SponsorService] Failed to fetch sponsor billing.', result.error);
throw new Error(result.error.details?.message || 'Failed to fetch sponsor billing');
}
const invoices: InvoiceDTO[] = [
{
id: 'inv-1',
invoiceNumber: 'GP-2025-001234',
date: '2025-11-01',
dueDate: '2025-11-15',
amount: 1090.91,
vatAmount: 207.27,
totalAmount: 1298.18,
status: 'paid',
description: 'GT3 Pro Championship - Primary Sponsor (Q4 2025)',
sponsorshipType: 'league',
pdfUrl: '#',
},
{
id: 'inv-2',
invoiceNumber: 'GP-2025-001235',
date: '2025-10-01',
dueDate: '2025-10-15',
amount: 363.64,
vatAmount: 69.09,
totalAmount: 432.73,
status: 'paid',
description: 'Team Velocity - Gear Sponsor (Q4 2025)',
sponsorshipType: 'team',
pdfUrl: '#',
},
{
id: 'inv-3',
invoiceNumber: 'GP-2025-001236',
date: '2025-12-01',
dueDate: '2025-12-15',
amount: 318.18,
vatAmount: 60.45,
totalAmount: 378.63,
status: 'pending',
description: 'Alex Thompson - Driver Sponsorship (Dec 2025)',
sponsorshipType: 'driver',
pdfUrl: '#',
},
];
const stats: BillingStatsDTO = {
totalSpent: 12450,
pendingAmount: 919.54,
nextPaymentDate: '2025-12-15',
nextPaymentAmount: 378.63,
activeSponsorships: 6,
averageMonthlySpend: 2075,
};
presenter.present({ paymentMethods, invoices, stats });
const presenter = new GetSponsorBillingPresenter();
presenter.present(result.value);
return presenter;
}

View File

@@ -1,4 +1,10 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { Result } from '@core/shared/application/Result';
import type {
GetSponsorsResult,
GetSponsorsErrorCode,
} from '@core/racing/application/use-cases/GetSponsorsUseCase';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import { GetSponsorsPresenter } from './GetSponsorsPresenter';
describe('GetSponsorsPresenter', () => {
@@ -9,54 +15,92 @@ describe('GetSponsorsPresenter', () => {
});
describe('reset', () => {
it('should reset the result to null', () => {
const mockResult = { sponsors: [] };
presenter.present(mockResult);
expect(presenter.viewModel).toEqual(mockResult);
it('should reset the model to null and cause responseModel to throw', () => {
const result = Result.ok<GetSponsorsResult, never>({ sponsors: [] });
presenter.present(result);
expect(presenter.responseModel).toEqual({ sponsors: [] });
presenter.reset();
expect(() => presenter.viewModel).toThrow('Presenter not presented');
expect(() => presenter.responseModel).toThrow('Presenter not presented');
});
});
describe('present', () => {
it('should store the result', () => {
const mockResult = {
it('should map Result.ok sponsors to DTO responseModel', () => {
const result = Result.ok<GetSponsorsResult, never>({
sponsors: [
{ id: 'sponsor-1', name: 'Sponsor One', contactEmail: 's1@example.com' },
{ id: 'sponsor-2', name: 'Sponsor Two', contactEmail: 's2@example.com' },
{
id: 'sponsor-1',
name: 'Sponsor One',
contactEmail: 's1@example.com',
logoUrl: 'logo1.png',
websiteUrl: 'https://one.example.com',
createdAt: new Date('2024-01-01T00:00:00Z'),
},
{
id: 'sponsor-2',
name: 'Sponsor Two',
contactEmail: 's2@example.com',
logoUrl: undefined,
websiteUrl: undefined,
createdAt: undefined,
},
],
};
});
presenter.present(mockResult);
presenter.present(result);
expect(presenter.viewModel).toEqual(mockResult);
expect(presenter.responseModel).toEqual({
sponsors: [
{
id: 'sponsor-1',
name: 'Sponsor One',
contactEmail: 's1@example.com',
logoUrl: 'logo1.png',
websiteUrl: 'https://one.example.com',
createdAt: new Date('2024-01-01T00:00:00Z'),
},
{
id: 'sponsor-2',
name: 'Sponsor Two',
contactEmail: 's2@example.com',
logoUrl: undefined,
websiteUrl: undefined,
createdAt: undefined,
},
],
});
});
});
describe('getViewModel', () => {
describe('getResponseModel', () => {
it('should return null when not presented', () => {
expect(presenter.getViewModel()).toBeNull();
expect(presenter.getResponseModel()).toBeNull();
});
it('should return the result when presented', () => {
const mockResult = { sponsors: [] };
presenter.present(mockResult);
it('should return the model when presented', () => {
const result = Result.ok<GetSponsorsResult, never>({ sponsors: [] });
presenter.present(result);
expect(presenter.getViewModel()).toEqual(mockResult);
expect(presenter.getResponseModel()).toEqual({ sponsors: [] });
});
});
describe('viewModel', () => {
describe('responseModel', () => {
it('should throw error when not presented', () => {
expect(() => presenter.viewModel).toThrow('Presenter not presented');
expect(() => presenter.responseModel).toThrow('Presenter not presented');
});
it('should return the result when presented', () => {
const mockResult = { sponsors: [] };
presenter.present(mockResult);
it('should fallback to empty sponsors list on error', () => {
const error = {
code: 'REPOSITORY_ERROR' as GetSponsorsErrorCode,
details: { message: 'DB error' },
} satisfies ApplicationErrorCode<GetSponsorsErrorCode, { message: string }>;
const result = Result.err<GetSponsorsResult, typeof error>(error);
expect(presenter.viewModel).toEqual(mockResult);
presenter.present(result);
expect(presenter.responseModel).toEqual({ sponsors: [] });
});
});
});

View File

@@ -1,25 +1,51 @@
import type { GetSponsorsOutputPort } from '@core/racing/application/ports/output/GetSponsorsOutputPort';
import type { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type {
GetSponsorsResult,
GetSponsorsErrorCode,
} from '@core/racing/application/use-cases/GetSponsorsUseCase';
import { GetSponsorsOutputDTO } from '../dtos/GetSponsorsOutputDTO';
import type { SponsorDTO } from '../dtos/SponsorDTO';
export class GetSponsorsPresenter {
private result: GetSponsorsOutputDTO | null = null;
private model: GetSponsorsOutputDTO | null = null;
reset() {
this.result = null;
this.model = null;
}
present(outputPort: GetSponsorsOutputPort) {
this.result = {
sponsors: outputPort.sponsors,
present(
result: Result<
GetSponsorsResult,
ApplicationErrorCode<GetSponsorsErrorCode, { message: string }>
>,
): void {
if (result.isErr()) {
// For sponsor listing, fall back to empty list on error
this.model = { sponsors: [] };
return;
}
const output = result.unwrap();
this.model = {
sponsors: output.sponsors.map<SponsorDTO>((sponsor) => ({
id: sponsor.id,
name: sponsor.name,
contactEmail: sponsor.contactEmail,
logoUrl: sponsor.logoUrl,
websiteUrl: sponsor.websiteUrl,
createdAt: sponsor.createdAt,
})),
};
}
getViewModel(): GetSponsorsOutputDTO | null {
return this.result;
getResponseModel(): GetSponsorsOutputDTO | null {
return this.model;
}
get viewModel(): GetSponsorsOutputDTO {
if (!this.result) throw new Error('Presenter not presented');
return this.result;
get responseModel(): GetSponsorsOutputDTO {
if (!this.model) throw new Error('Presenter not presented');
return this.model;
}
}