This commit is contained in:
2025-12-21 17:05:36 +01:00
parent 08b0d59e45
commit f2d8a23583
66 changed files with 1131 additions and 1342 deletions

View File

@@ -1,2 +1 @@
export * from './presenters';
export * from './use-cases';

View File

@@ -1,16 +0,0 @@
/**
* Presenter Interface: IAwardPrizePresenter
*/
import type { Presenter } from '@core/shared/presentation/Presenter';
import type { PrizeDto } from './IGetPrizesPresenter';
export interface AwardPrizeResultDTO {
prize: PrizeDto;
}
export interface AwardPrizeViewModel {
prize: PrizeDto;
}
export interface IAwardPrizePresenter extends Presenter<AwardPrizeResultDTO, AwardPrizeViewModel> {}

View File

@@ -1,16 +0,0 @@
/**
* Presenter Interface: ICreatePaymentPresenter
*/
import type { Presenter } from '@core/shared/presentation/Presenter';
import type { PaymentDto } from './IGetPaymentsPresenter';
export interface CreatePaymentResultDTO {
payment: PaymentDto;
}
export interface CreatePaymentViewModel {
payment: PaymentDto;
}
export interface ICreatePaymentPresenter extends Presenter<CreatePaymentResultDTO, CreatePaymentViewModel> {}

View File

@@ -1,16 +0,0 @@
/**
* Presenter Interface: ICreatePrizePresenter
*/
import type { Presenter } from '@core/shared/presentation/Presenter';
import type { PrizeDto } from './IGetPrizesPresenter';
export interface CreatePrizeResultDTO {
prize: PrizeDto;
}
export interface CreatePrizeViewModel {
prize: PrizeDto;
}
export interface ICreatePrizePresenter extends Presenter<CreatePrizeResultDTO, CreatePrizeViewModel> {}

View File

@@ -1,15 +0,0 @@
/**
* Presenter Interface: IDeletePrizePresenter
*/
import type { Presenter } from '@core/shared/presentation/Presenter';
export interface DeletePrizeResultDTO {
success: boolean;
}
export interface DeletePrizeViewModel {
success: boolean;
}
export interface IDeletePrizePresenter extends Presenter<DeletePrizeResultDTO, DeletePrizeViewModel> {}

View File

@@ -1,42 +0,0 @@
/**
* Presenter Interface: IGetMembershipFeesPresenter
*/
import type { Presenter } from '@core/shared/presentation/Presenter';
import type { MembershipFeeType } from '../../domain/entities/MembershipFee';
import type { MemberPaymentStatus } from '../../domain/entities/MemberPayment';
export interface MembershipFeeDto {
id: string;
leagueId: string;
seasonId?: string;
type: MembershipFeeType;
amount: number;
enabled: boolean;
createdAt: Date;
updatedAt: Date;
}
export interface MemberPaymentDto {
id: string;
feeId: string;
driverId: string;
amount: number;
platformFee: number;
netAmount: number;
status: MemberPaymentStatus;
dueDate: Date;
paidAt?: Date;
}
export interface GetMembershipFeesResultDTO {
fee: MembershipFeeDto | null;
payments: MemberPaymentDto[];
}
export interface GetMembershipFeesViewModel {
fee: MembershipFeeDto | null;
payments: MemberPaymentDto[];
}
export interface IGetMembershipFeesPresenter extends Presenter<GetMembershipFeesResultDTO, GetMembershipFeesViewModel> {}

View File

@@ -1,31 +0,0 @@
/**
* Presenter Interface: IGetPaymentsPresenter
*/
import type { Presenter } from '@core/shared/presentation/Presenter';
import type { PaymentType, PayerType, PaymentStatus } from '../../domain/entities/Payment';
export interface PaymentDto {
id: string;
type: PaymentType;
amount: number;
platformFee: number;
netAmount: number;
payerId: string;
payerType: PayerType;
leagueId: string;
seasonId?: string;
status: PaymentStatus;
createdAt: Date;
completedAt?: Date;
}
export interface GetPaymentsResultDTO {
payments: PaymentDto[];
}
export interface GetPaymentsViewModel {
payments: PaymentDto[];
}
export interface IGetPaymentsPresenter extends Presenter<GetPaymentsResultDTO, GetPaymentsViewModel> {}

View File

@@ -1,31 +0,0 @@
/**
* Presenter Interface: IGetPrizesPresenter
*/
import type { Presenter } from '@core/shared/presentation/Presenter';
import type { PrizeType } from '../../domain/entities/Prize';
export interface PrizeDto {
id: string;
leagueId: string;
seasonId: string;
position: number;
name: string;
amount: number;
type: PrizeType;
description?: string;
awarded: boolean;
awardedTo?: string;
awardedAt?: Date;
createdAt: Date;
}
export interface GetPrizesResultDTO {
prizes: PrizeDto[];
}
export interface GetPrizesViewModel {
prizes: PrizeDto[];
}
export interface IGetPrizesPresenter extends Presenter<GetPrizesResultDTO, GetPrizesViewModel> {}

View File

@@ -1,40 +0,0 @@
/**
* Presenter Interface: IGetWalletPresenter
*/
import type { Presenter } from '@core/shared/presentation/Presenter';
import type { TransactionType, ReferenceType } from '../../domain/entities/Wallet';
export interface WalletDto {
id: string;
leagueId: string;
balance: number;
totalRevenue: number;
totalPlatformFees: number;
totalWithdrawn: number;
currency: string;
createdAt: Date;
}
export interface TransactionDto {
id: string;
walletId: string;
type: TransactionType;
amount: number;
description: string;
referenceId?: string;
referenceType?: ReferenceType;
createdAt: Date;
}
export interface GetWalletResultDTO {
wallet: WalletDto;
transactions: TransactionDto[];
}
export interface GetWalletViewModel {
wallet: WalletDto;
transactions: TransactionDto[];
}
export interface IGetWalletPresenter extends Presenter<GetWalletResultDTO, GetWalletViewModel> {}

View File

@@ -1,18 +0,0 @@
/**
* Presenter Interface: IProcessWalletTransactionPresenter
*/
import type { Presenter } from '@core/shared/presentation/Presenter';
import type { WalletDto, TransactionDto } from './IGetWalletPresenter';
export interface ProcessWalletTransactionResultDTO {
wallet: WalletDto;
transaction: TransactionDto;
}
export interface ProcessWalletTransactionViewModel {
wallet: WalletDto;
transaction: TransactionDto;
}
export interface IProcessWalletTransactionPresenter extends Presenter<ProcessWalletTransactionResultDTO, ProcessWalletTransactionViewModel> {}

View File

@@ -1,16 +0,0 @@
/**
* Presenter Interface: IUpdateMemberPaymentPresenter
*/
import type { Presenter } from '@core/shared/presentation/Presenter';
import type { MemberPaymentDto } from './IGetMembershipFeesPresenter';
export interface UpdateMemberPaymentResultDTO {
payment: MemberPaymentDto;
}
export interface UpdateMemberPaymentViewModel {
payment: MemberPaymentDto;
}
export interface IUpdateMemberPaymentPresenter extends Presenter<UpdateMemberPaymentResultDTO, UpdateMemberPaymentViewModel> {}

View File

@@ -1,16 +0,0 @@
/**
* Presenter Interface: IUpdatePaymentStatusPresenter
*/
import type { Presenter } from '@core/shared/presentation/Presenter';
import type { PaymentDto } from './IGetPaymentsPresenter';
export interface UpdatePaymentStatusResultDTO {
payment: PaymentDto;
}
export interface UpdatePaymentStatusViewModel {
payment: PaymentDto;
}
export interface IUpdatePaymentStatusPresenter extends Presenter<UpdatePaymentStatusResultDTO, UpdatePaymentStatusViewModel> {}

View File

@@ -1,16 +0,0 @@
/**
* Presenter Interface: IUpsertMembershipFeePresenter
*/
import type { Presenter } from '@core/shared/presentation/Presenter';
import type { MembershipFeeDto } from './IGetMembershipFeesPresenter';
export interface UpsertMembershipFeeResultDTO {
fee: MembershipFeeDto;
}
export interface UpsertMembershipFeeViewModel {
fee: MembershipFeeDto;
}
export interface IUpsertMembershipFeePresenter extends Presenter<UpsertMembershipFeeResultDTO, UpsertMembershipFeeViewModel> {}

View File

@@ -1,12 +0,0 @@
export * from './IGetPaymentsPresenter';
export * from './ICreatePaymentPresenter';
export * from './IUpdatePaymentStatusPresenter';
export * from './IGetMembershipFeesPresenter';
export * from './IUpsertMembershipFeePresenter';
export * from './IUpdateMemberPaymentPresenter';
export * from './IGetPrizesPresenter';
export * from './ICreatePrizePresenter';
export * from './IAwardPrizePresenter';
export * from './IDeletePrizePresenter';
export * from './IGetWalletPresenter';
export * from './IProcessWalletTransactionPresenter';

View File

@@ -5,38 +5,41 @@
*/
import type { IPrizeRepository } from '../../domain/repositories/IPrizeRepository';
import type {
IAwardPrizePresenter,
AwardPrizeResultDTO,
AwardPrizeViewModel,
} from '../presenters/IAwardPrizePresenter';
import type { Prize } from '../../domain/entities/Prize';
import type { UseCase } from '@core/shared/application/UseCase';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
export interface AwardPrizeInput {
prizeId: string;
driverId: string;
}
export interface AwardPrizeResult {
prize: Prize;
}
export type AwardPrizeErrorCode = 'PRIZE_NOT_FOUND' | 'PRIZE_ALREADY_AWARDED';
export class AwardPrizeUseCase
implements UseCase<AwardPrizeInput, AwardPrizeResultDTO, AwardPrizeViewModel, IAwardPrizePresenter>
implements UseCase<AwardPrizeInput, void, AwardPrizeErrorCode>
{
constructor(private readonly prizeRepository: IPrizeRepository) {}
async execute(
input: AwardPrizeInput,
presenter: IAwardPrizePresenter,
): Promise<void> {
presenter.reset();
constructor(
private readonly prizeRepository: IPrizeRepository,
private readonly output: UseCaseOutputPort<AwardPrizeResult>,
) {}
async execute(input: AwardPrizeInput): Promise<Result<void, ApplicationErrorCode<AwardPrizeErrorCode>>> {
const { prizeId, driverId } = input;
const prize = await this.prizeRepository.findById(prizeId);
if (!prize) {
throw new Error('Prize not found');
return Result.err({ code: 'PRIZE_NOT_FOUND' as const });
}
if (prize.awarded) {
throw new Error('Prize has already been awarded');
return Result.err({ code: 'PRIZE_ALREADY_AWARDED' as const });
}
prize.awarded = true;
@@ -45,23 +48,8 @@ export class AwardPrizeUseCase
const updatedPrize = await this.prizeRepository.update(prize);
const dto: AwardPrizeResultDTO = {
prize: {
id: updatedPrize.id,
leagueId: updatedPrize.leagueId,
seasonId: updatedPrize.seasonId,
position: updatedPrize.position,
name: updatedPrize.name,
amount: updatedPrize.amount,
type: updatedPrize.type,
description: updatedPrize.description,
awarded: updatedPrize.awarded,
awardedTo: updatedPrize.awardedTo,
awardedAt: updatedPrize.awardedAt,
createdAt: updatedPrize.createdAt,
},
};
this.output.present({ prize: updatedPrize });
presenter.present(dto);
return Result.ok(undefined);
}
}

View File

@@ -0,0 +1,76 @@
import { describe, it, expect, vi, type Mock } from 'vitest';
import { CreatePaymentUseCase, type CreatePaymentInput } from './CreatePaymentUseCase';
import type { IPaymentRepository } from '../../domain/repositories/IPaymentRepository';
import { PaymentType, PayerType } from '../../domain/entities/Payment';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
describe('CreatePaymentUseCase', () => {
let paymentRepository: {
create: Mock;
};
let output: {
present: Mock;
};
let useCase: CreatePaymentUseCase;
beforeEach(() => {
paymentRepository = {
create: vi.fn(),
} as unknown as IPaymentRepository as any;
output = {
present: vi.fn(),
};
useCase = new CreatePaymentUseCase(
paymentRepository as unknown as IPaymentRepository,
output as unknown as UseCaseOutputPort<any>,
);
});
it('creates a payment and presents the result', async () => {
const input: CreatePaymentInput = {
type: PaymentType.SPONSORSHIP,
amount: 100,
payerId: 'payer-1',
payerType: PayerType.SPONSOR,
leagueId: 'league-1',
seasonId: 'season-1',
};
const createdPayment = {
id: 'payment-123',
type: PaymentType.SPONSORSHIP,
amount: 100,
platformFee: 5,
netAmount: 95,
payerId: 'payer-1',
payerType: PayerType.SPONSOR,
leagueId: 'league-1',
seasonId: 'season-1',
status: 'pending',
createdAt: new Date(),
completedAt: undefined,
};
paymentRepository.create.mockResolvedValue(createdPayment);
const result = await useCase.execute(input);
expect(result.isOk()).toBe(true);
expect(paymentRepository.create).toHaveBeenCalledWith({
id: expect.stringContaining('payment-'),
type: 'sponsorship',
amount: 100,
platformFee: 5,
netAmount: 95,
payerId: 'payer-1',
payerType: 'sponsor',
leagueId: 'league-1',
seasonId: 'season-1',
status: 'pending',
createdAt: expect.any(Date),
});
expect(output.present).toHaveBeenCalledWith({ payment: createdPayment });
});
});

View File

@@ -5,14 +5,12 @@
*/
import type { IPaymentRepository } from '../../domain/repositories/IPaymentRepository';
import type { Payment, PaymentType, PayerType, PaymentStatus } from '../../domain/entities/Payment';
import type {
ICreatePaymentPresenter,
CreatePaymentResultDTO,
CreatePaymentViewModel,
PaymentDto,
} from '../presenters/ICreatePaymentPresenter';
import type { Payment, PaymentType, PayerType } from '../../domain/entities/Payment';
import { PaymentStatus } from '../../domain/entities/Payment';
import type { UseCase } from '@core/shared/application/UseCase';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
export interface CreatePaymentInput {
type: PaymentType;
@@ -23,17 +21,21 @@ export interface CreatePaymentInput {
seasonId?: string;
}
export interface CreatePaymentResult {
payment: Payment;
}
export type CreatePaymentErrorCode = never;
export class CreatePaymentUseCase
implements UseCase<CreatePaymentInput, CreatePaymentResultDTO, CreatePaymentViewModel, ICreatePaymentPresenter>
implements UseCase<CreatePaymentInput, void, CreatePaymentErrorCode>
{
constructor(private readonly paymentRepository: IPaymentRepository) {}
async execute(
input: CreatePaymentInput,
presenter: ICreatePaymentPresenter,
): Promise<void> {
presenter.reset();
constructor(
private readonly paymentRepository: IPaymentRepository,
private readonly output: UseCaseOutputPort<CreatePaymentResult>,
) {}
async execute(input: CreatePaymentInput): Promise<Result<void, ApplicationErrorCode<CreatePaymentErrorCode>>> {
const { type, amount, payerId, payerType, leagueId, seasonId } = input;
// Calculate platform fee (assume 5% for now)
@@ -50,32 +52,15 @@ export class CreatePaymentUseCase
payerId,
payerType,
leagueId,
seasonId,
status: PaymentStatus.PENDING,
createdAt: new Date(),
...(seasonId !== undefined ? { seasonId } : {}),
};
const createdPayment = await this.paymentRepository.create(payment);
const dto: PaymentDto = {
id: createdPayment.id,
type: createdPayment.type,
amount: createdPayment.amount,
platformFee: createdPayment.platformFee,
netAmount: createdPayment.netAmount,
payerId: createdPayment.payerId,
payerType: createdPayment.payerType,
leagueId: createdPayment.leagueId,
seasonId: createdPayment.seasonId,
status: createdPayment.status,
createdAt: createdPayment.createdAt,
completedAt: createdPayment.completedAt,
};
this.output.present({ payment: createdPayment });
const result: CreatePaymentResultDTO = {
payment: dto,
};
presenter.present(result);
return Result.ok(undefined);
}
}

View File

@@ -6,12 +6,10 @@
import type { IPrizeRepository } from '../../domain/repositories/IPrizeRepository';
import type { PrizeType, Prize } from '../../domain/entities/Prize';
import type {
ICreatePrizePresenter,
CreatePrizeResultDTO,
CreatePrizeViewModel,
} from '../presenters/ICreatePrizePresenter';
import type { UseCase } from '@core/shared/application/UseCase';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
export interface CreatePrizeInput {
leagueId: string;
@@ -23,22 +21,26 @@ export interface CreatePrizeInput {
description?: string;
}
export interface CreatePrizeResult {
prize: Prize;
}
export type CreatePrizeErrorCode = 'PRIZE_ALREADY_EXISTS';
export class CreatePrizeUseCase
implements UseCase<CreatePrizeInput, CreatePrizeResultDTO, CreatePrizeViewModel, ICreatePrizePresenter>
implements UseCase<CreatePrizeInput, void, CreatePrizeErrorCode>
{
constructor(private readonly prizeRepository: IPrizeRepository) {}
async execute(
input: CreatePrizeInput,
presenter: ICreatePrizePresenter,
): Promise<void> {
presenter.reset();
constructor(
private readonly prizeRepository: IPrizeRepository,
private readonly output: UseCaseOutputPort<CreatePrizeResult>,
) {}
async execute(input: CreatePrizeInput): Promise<Result<void, ApplicationErrorCode<CreatePrizeErrorCode>>> {
const { leagueId, seasonId, position, name, amount, type, description } = input;
const existingPrize = await this.prizeRepository.findByPosition(leagueId, seasonId, position);
if (existingPrize) {
throw new Error(`Prize for position ${position} already exists`);
return Result.err({ code: 'PRIZE_ALREADY_EXISTS' as const });
}
const id = `prize-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
@@ -50,30 +52,15 @@ export class CreatePrizeUseCase
name,
amount,
type,
description,
awarded: false,
createdAt: new Date(),
...(description !== undefined ? { description } : {}),
};
const createdPrize = await this.prizeRepository.create(prize);
const dto: CreatePrizeResultDTO = {
prize: {
id: createdPrize.id,
leagueId: createdPrize.leagueId,
seasonId: createdPrize.seasonId,
position: createdPrize.position,
name: createdPrize.name,
amount: createdPrize.amount,
type: createdPrize.type,
description: createdPrize.description,
awarded: createdPrize.awarded,
awardedTo: createdPrize.awardedTo,
awardedAt: createdPrize.awardedAt,
createdAt: createdPrize.createdAt,
},
};
this.output.present({ prize: createdPrize });
presenter.present(dto);
return Result.ok(undefined);
}
}

View File

@@ -5,45 +5,45 @@
*/
import type { IPrizeRepository } from '../../domain/repositories/IPrizeRepository';
import type {
IDeletePrizePresenter,
DeletePrizeResultDTO,
DeletePrizeViewModel,
} from '../presenters/IDeletePrizePresenter';
import type { UseCase } from '@core/shared/application/UseCase';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
export interface DeletePrizeInput {
prizeId: string;
}
export interface DeletePrizeResult {
success: boolean;
}
export type DeletePrizeErrorCode = 'PRIZE_NOT_FOUND' | 'CANNOT_DELETE_AWARDED_PRIZE';
export class DeletePrizeUseCase
implements UseCase<DeletePrizeInput, DeletePrizeResultDTO, DeletePrizeViewModel, IDeletePrizePresenter>
implements UseCase<DeletePrizeInput, void, DeletePrizeErrorCode>
{
constructor(private readonly prizeRepository: IPrizeRepository) {}
async execute(
input: DeletePrizeInput,
presenter: IDeletePrizePresenter,
): Promise<void> {
presenter.reset();
constructor(
private readonly prizeRepository: IPrizeRepository,
private readonly output: UseCaseOutputPort<DeletePrizeResult>,
) {}
async execute(input: DeletePrizeInput): Promise<Result<void, ApplicationErrorCode<DeletePrizeErrorCode>>> {
const { prizeId } = input;
const prize = await this.prizeRepository.findById(prizeId);
if (!prize) {
throw new Error('Prize not found');
return Result.err({ code: 'PRIZE_NOT_FOUND' as const });
}
if (prize.awarded) {
throw new Error('Cannot delete an awarded prize');
return Result.err({ code: 'CANNOT_DELETE_AWARDED_PRIZE' as const });
}
await this.prizeRepository.delete(prizeId);
const dto: DeletePrizeResultDTO = {
success: true,
};
this.output.present({ success: true });
presenter.present(dto);
return Result.ok(undefined);
}
}

View File

@@ -1,14 +1,7 @@
import { describe, it, expect, vi, type Mock } from 'vitest';
import { GetMembershipFeesUseCase, type GetMembershipFeesInput } from './GetMembershipFeesUseCase';
import type { IMembershipFeeRepository, IMemberPaymentRepository } from '../../domain/repositories/IMembershipFeeRepository';
import type { IGetMembershipFeesPresenter, GetMembershipFeesResultDTO, GetMembershipFeesViewModel } from '../presenters/IGetMembershipFeesPresenter';
interface TestPresenter extends IGetMembershipFeesPresenter {
reset: Mock;
present: Mock;
lastDto?: GetMembershipFeesResultDTO;
viewModel?: GetMembershipFeesViewModel;
}
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
describe('GetMembershipFeesUseCase', () => {
let membershipFeeRepository: {
@@ -17,7 +10,9 @@ describe('GetMembershipFeesUseCase', () => {
let memberPaymentRepository: {
findByLeagueIdAndDriverId: Mock;
};
let presenter: TestPresenter;
let output: {
present: Mock;
};
let useCase: GetMembershipFeesUseCase;
beforeEach(() => {
@@ -29,28 +24,24 @@ describe('GetMembershipFeesUseCase', () => {
findByLeagueIdAndDriverId: vi.fn(),
} as unknown as IMemberPaymentRepository as any;
presenter = {
reset: vi.fn(),
present: vi.fn((dto: GetMembershipFeesResultDTO) => {
presenter.lastDto = dto;
}),
toViewModel: vi.fn((dto: GetMembershipFeesResultDTO) => ({
fee: dto.fee,
payments: dto.payments,
})),
} as unknown as TestPresenter;
output = {
present: vi.fn(),
};
useCase = new GetMembershipFeesUseCase(
membershipFeeRepository as unknown as IMembershipFeeRepository,
memberPaymentRepository as unknown as IMemberPaymentRepository,
output as unknown as UseCaseOutputPort<any>,
);
});
it('throws when leagueId is missing', async () => {
it('returns error when leagueId is missing', async () => {
const input = { leagueId: '' } as GetMembershipFeesInput;
await expect(useCase.execute(input, presenter)).rejects.toThrow('leagueId is required');
expect(presenter.reset).toHaveBeenCalled();
const result = await useCase.execute(input);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().code).toBe('INVALID_INPUT');
});
it('returns null fee and empty payments when no fee exists', async () => {
@@ -58,11 +49,12 @@ describe('GetMembershipFeesUseCase', () => {
membershipFeeRepository.findByLeagueId.mockResolvedValue(null);
await useCase.execute(input, presenter);
const result = await useCase.execute(input);
expect(result.isOk()).toBe(true);
expect(membershipFeeRepository.findByLeagueId).toHaveBeenCalledWith('league-1');
expect(memberPaymentRepository.findByLeagueIdAndDriverId).not.toHaveBeenCalled();
expect(presenter.present).toHaveBeenCalledWith({
expect(output.present).toHaveBeenCalledWith({
fee: null,
payments: [],
});
@@ -99,35 +91,15 @@ describe('GetMembershipFeesUseCase', () => {
membershipFeeRepository.findByLeagueId.mockResolvedValue(fee);
memberPaymentRepository.findByLeagueIdAndDriverId.mockResolvedValue(payments);
await useCase.execute(input, presenter);
const result = await useCase.execute(input);
expect(result.isOk()).toBe(true);
expect(membershipFeeRepository.findByLeagueId).toHaveBeenCalledWith('league-1');
expect(memberPaymentRepository.findByLeagueIdAndDriverId).toHaveBeenCalledWith('league-1', 'driver-1', membershipFeeRepository as unknown as IMembershipFeeRepository);
expect(presenter.present).toHaveBeenCalledWith({
fee: {
id: fee.id,
leagueId: fee.leagueId,
seasonId: fee.seasonId,
type: fee.type,
amount: fee.amount,
enabled: fee.enabled,
createdAt: fee.createdAt,
updatedAt: fee.updatedAt,
},
payments: [
{
id: 'pay-1',
feeId: 'fee-1',
driverId: 'driver-1',
amount: 100,
platformFee: 5,
netAmount: 95,
status: 'paid',
dueDate: payments[0].dueDate,
paidAt: payments[0].paidAt,
},
],
expect(output.present).toHaveBeenCalledWith({
fee,
payments,
});
});
});

View File

@@ -5,70 +5,50 @@
*/
import type { IMembershipFeeRepository, IMemberPaymentRepository } from '../../domain/repositories/IMembershipFeeRepository';
import type {
IGetMembershipFeesPresenter,
GetMembershipFeesResultDTO,
GetMembershipFeesViewModel,
} from '../presenters/IGetMembershipFeesPresenter';
import type { MembershipFee } from '../../domain/entities/MembershipFee';
import type { MemberPayment } from '../../domain/entities/MemberPayment';
import type { UseCase } from '@core/shared/application/UseCase';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
export type GetMembershipFeesErrorCode = 'INVALID_INPUT';
export interface GetMembershipFeesInput {
leagueId: string;
driverId?: string;
}
export interface GetMembershipFeesResult {
fee: MembershipFee | null;
payments: MemberPayment[];
}
export class GetMembershipFeesUseCase
implements UseCase<GetMembershipFeesInput, GetMembershipFeesResultDTO, GetMembershipFeesViewModel, IGetMembershipFeesPresenter>
implements UseCase<GetMembershipFeesInput, void, GetMembershipFeesErrorCode>
{
constructor(
private readonly membershipFeeRepository: IMembershipFeeRepository,
private readonly memberPaymentRepository: IMemberPaymentRepository,
private readonly output: UseCaseOutputPort<GetMembershipFeesResult>,
) {}
async execute(
input: GetMembershipFeesInput,
presenter: IGetMembershipFeesPresenter,
): Promise<void> {
presenter.reset();
async execute(input: GetMembershipFeesInput): Promise<Result<void, ApplicationErrorCode<GetMembershipFeesErrorCode>>> {
const { leagueId, driverId } = input;
if (!leagueId) {
throw new Error('leagueId is required');
return Result.err({ code: 'INVALID_INPUT' as const });
}
const fee = await this.membershipFeeRepository.findByLeagueId(leagueId);
let payments: unknown[] = [];
let payments: MemberPayment[] = [];
if (driverId && fee) {
const memberPayments = await this.memberPaymentRepository.findByLeagueIdAndDriverId(leagueId, driverId, this.membershipFeeRepository);
payments = memberPayments.map(p => ({
id: p.id,
feeId: p.feeId,
driverId: p.driverId,
amount: p.amount,
platformFee: p.platformFee,
netAmount: p.netAmount,
status: p.status,
dueDate: p.dueDate,
paidAt: p.paidAt,
}));
payments = await this.memberPaymentRepository.findByLeagueIdAndDriverId(leagueId, driverId, this.membershipFeeRepository);
}
const dto: GetMembershipFeesResultDTO = {
fee: fee ? {
id: fee.id,
leagueId: fee.leagueId,
seasonId: fee.seasonId,
type: fee.type,
amount: fee.amount,
enabled: fee.enabled,
createdAt: fee.createdAt,
updatedAt: fee.updatedAt,
} : null,
payments,
};
this.output.present({ fee, payments });
presenter.present(dto);
return Result.ok(undefined);
}
}

View File

@@ -0,0 +1,67 @@
import { describe, it, expect, vi, type Mock } from 'vitest';
import { GetPaymentsUseCase, type GetPaymentsInput } from './GetPaymentsUseCase';
import type { IPaymentRepository } from '../../domain/repositories/IPaymentRepository';
import { PaymentType, PayerType } from '../../domain/entities/Payment';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
describe('GetPaymentsUseCase', () => {
let paymentRepository: {
findByFilters: Mock;
};
let output: {
present: Mock;
};
let useCase: GetPaymentsUseCase;
beforeEach(() => {
paymentRepository = {
findByFilters: vi.fn(),
} as unknown as IPaymentRepository as any;
output = {
present: vi.fn(),
};
useCase = new GetPaymentsUseCase(
paymentRepository as unknown as IPaymentRepository,
output as unknown as UseCaseOutputPort<any>,
);
});
it('retrieves payments and presents the result', async () => {
const input: GetPaymentsInput = {
leagueId: 'league-1',
payerId: 'payer-1',
type: PaymentType.SPONSORSHIP,
};
const payments = [
{
id: 'payment-1',
type: PaymentType.SPONSORSHIP,
amount: 100,
platformFee: 5,
netAmount: 95,
payerId: 'payer-1',
payerType: PayerType.SPONSOR,
leagueId: 'league-1',
seasonId: 'season-1',
status: 'completed',
createdAt: new Date(),
completedAt: new Date(),
},
];
paymentRepository.findByFilters.mockResolvedValue(payments);
const result = await useCase.execute(input);
expect(result.isOk()).toBe(true);
expect(paymentRepository.findByFilters).toHaveBeenCalledWith({
leagueId: 'league-1',
payerId: 'payer-1',
type: PaymentType.SPONSORSHIP,
});
expect(output.present).toHaveBeenCalledWith({ payments });
});
});

View File

@@ -5,14 +5,11 @@
*/
import type { IPaymentRepository } from '../../domain/repositories/IPaymentRepository';
import type { PaymentType } from '../../domain/entities/Payment';
import type {
IGetPaymentsPresenter,
GetPaymentsResultDTO,
GetPaymentsViewModel,
PaymentDto,
} from '../presenters/IGetPaymentsPresenter';
import type { Payment, PaymentType } from '../../domain/entities/Payment';
import type { UseCase } from '@core/shared/application/UseCase';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
export interface GetPaymentsInput {
leagueId?: string;
@@ -20,40 +17,32 @@ export interface GetPaymentsInput {
type?: PaymentType;
}
export interface GetPaymentsResult {
payments: Payment[];
}
export type GetPaymentsErrorCode = never;
export class GetPaymentsUseCase
implements UseCase<GetPaymentsInput, GetPaymentsResultDTO, GetPaymentsViewModel, IGetPaymentsPresenter>
implements UseCase<GetPaymentsInput, void, GetPaymentsErrorCode>
{
constructor(private readonly paymentRepository: IPaymentRepository) {}
async execute(
input: GetPaymentsInput,
presenter: IGetPaymentsPresenter,
): Promise<void> {
presenter.reset();
constructor(
private readonly paymentRepository: IPaymentRepository,
private readonly output: UseCaseOutputPort<GetPaymentsResult>,
) {}
async execute(input: GetPaymentsInput): Promise<Result<void, ApplicationErrorCode<GetPaymentsErrorCode>>> {
const { leagueId, payerId, type } = input;
const payments = await this.paymentRepository.findByFilters({ leagueId, payerId, type });
const filters: { leagueId?: string; payerId?: string; type?: PaymentType } = {};
if (leagueId !== undefined) filters.leagueId = leagueId;
if (payerId !== undefined) filters.payerId = payerId;
if (type !== undefined) filters.type = type;
const dtos: PaymentDto[] = payments.map(payment => ({
id: payment.id,
type: payment.type,
amount: payment.amount,
platformFee: payment.platformFee,
netAmount: payment.netAmount,
payerId: payment.payerId,
payerType: payment.payerType,
leagueId: payment.leagueId,
seasonId: payment.seasonId,
status: payment.status,
createdAt: payment.createdAt,
completedAt: payment.completedAt,
}));
const payments = await this.paymentRepository.findByFilters(filters);
const dto: GetPaymentsResultDTO = {
payments: dtos,
};
this.output.present({ payments });
presenter.present(dto);
return Result.ok(undefined);
}
}

View File

@@ -5,29 +5,29 @@
*/
import type { IPrizeRepository } from '../../domain/repositories/IPrizeRepository';
import type {
IGetPrizesPresenter,
GetPrizesResultDTO,
GetPrizesViewModel,
} from '../presenters/IGetPrizesPresenter';
import type { Prize } from '../../domain/entities/Prize';
import type { UseCase } from '@core/shared/application/UseCase';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import { Result } from '@core/shared/application/Result';
export interface GetPrizesInput {
leagueId: string;
seasonId?: string;
}
export interface GetPrizesResult {
prizes: Prize[];
}
export class GetPrizesUseCase
implements UseCase<GetPrizesInput, GetPrizesResultDTO, GetPrizesViewModel, IGetPrizesPresenter>
implements UseCase<GetPrizesInput, void, never>
{
constructor(private readonly prizeRepository: IPrizeRepository) {}
async execute(
input: GetPrizesInput,
presenter: IGetPrizesPresenter,
): Promise<void> {
presenter.reset();
constructor(
private readonly prizeRepository: IPrizeRepository,
private readonly output: UseCaseOutputPort<GetPrizesResult>,
) {}
async execute(input: GetPrizesInput): Promise<Result<void, never>> {
const { leagueId, seasonId } = input;
let prizes;
@@ -39,23 +39,8 @@ export class GetPrizesUseCase
prizes.sort((a, b) => a.position - b.position);
const dto: GetPrizesResultDTO = {
prizes: prizes.map(prize => ({
id: prize.id,
leagueId: prize.leagueId,
seasonId: prize.seasonId,
position: prize.position,
name: prize.name,
amount: prize.amount,
type: prize.type,
description: prize.description,
awarded: prize.awarded,
awardedTo: prize.awardedTo,
awardedAt: prize.awardedAt,
createdAt: prize.createdAt,
})),
};
this.output.present({ prizes });
presenter.present(dto);
return Result.ok(undefined);
}
}

View File

@@ -1,6 +1,9 @@
import type { IPaymentRepository } from '@core/payments/domain/repositories/IPaymentRepository';
import { PaymentStatus, PaymentType, PayerType } from '@core/payments/domain/entities/Payment';
import { PaymentStatus, PaymentType } from '../../domain/entities/Payment';
import type { ISeasonSponsorshipRepository } from '@core/racing/domain/repositories/ISeasonSponsorshipRepository';
import type { IPaymentRepository } from '../../domain/repositories/IPaymentRepository';
import type { UseCase } from '@core/shared/application/UseCase';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
export interface SponsorBillingStats {
totalSpent: number;
@@ -42,18 +45,29 @@ export interface SponsorBillingSummary {
stats: SponsorBillingStats;
}
/**
* Application Service: SponsorBillingService
*
* Aggregates sponsor-facing billing information from payments and season sponsorships.
*/
export class SponsorBillingService {
export interface GetSponsorBillingInput {
sponsorId: string;
}
export interface GetSponsorBillingResult {
paymentMethods: SponsorPaymentMethodSummary[];
invoices: SponsorInvoiceSummary[];
stats: SponsorBillingStats;
}
export type GetSponsorBillingErrorCode = never;
export class GetSponsorBillingUseCase
implements UseCase<GetSponsorBillingInput, GetSponsorBillingResult, GetSponsorBillingErrorCode>
{
constructor(
private readonly paymentRepository: IPaymentRepository,
private readonly seasonSponsorshipRepository: ISeasonSponsorshipRepository,
) {}
async getSponsorBilling(sponsorId: string): Promise<SponsorBillingSummary> {
async execute(input: GetSponsorBillingInput): Promise<Result<GetSponsorBillingResult, ApplicationErrorCode<GetSponsorBillingErrorCode>>> {
const { sponsorId } = input;
// In this in-memory implementation we derive billing data from payments
// where the sponsor is the payer.
const payments = await this.paymentRepository.findByFilters({
@@ -122,11 +136,13 @@ export class SponsorBillingService {
// payment-methods port can be added later when the concept exists in core.
const paymentMethods: SponsorPaymentMethodSummary[] = [];
return {
const result: GetSponsorBillingResult = {
paymentMethods,
invoices,
stats,
};
return Result.ok(result);
}
private calculateAverageMonthlySpend(invoices: SponsorInvoiceSummary[]): number {
@@ -147,4 +163,4 @@ export class SponsorBillingService {
const months = d2.getMonth() - d1.getMonth();
return years * 12 + months + 1;
}
}
}

View File

@@ -5,40 +5,41 @@
*/
import type { IWalletRepository, ITransactionRepository } from '../../domain/repositories/IWalletRepository';
import type { Wallet } from '../../domain/entities/Wallet';
import type {
IGetWalletPresenter,
GetWalletResultDTO,
GetWalletViewModel,
} from '../presenters/IGetWalletPresenter';
import type { Wallet, Transaction } from '../../domain/entities/Wallet';
import type { UseCase } from '@core/shared/application/UseCase';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
export type GetWalletErrorCode = 'INVALID_INPUT';
export interface GetWalletInput {
leagueId: string;
}
export interface GetWalletResult {
wallet: Wallet;
transactions: Transaction[];
}
export class GetWalletUseCase
implements UseCase<GetWalletInput, GetWalletResultDTO, GetWalletViewModel, IGetWalletPresenter>
implements UseCase<GetWalletInput, void, GetWalletErrorCode>
{
constructor(
private readonly walletRepository: IWalletRepository,
private readonly transactionRepository: ITransactionRepository,
private readonly output: UseCaseOutputPort<GetWalletResult>,
) {}
async execute(
input: GetWalletInput,
presenter: IGetWalletPresenter,
): Promise<void> {
presenter.reset();
async execute(input: GetWalletInput): Promise<Result<void, ApplicationErrorCode<GetWalletErrorCode>>> {
const { leagueId } = input;
if (!leagueId) {
throw new Error('LeagueId is required');
return Result.err({ code: 'INVALID_INPUT' as const });
}
let wallet = await this.walletRepository.findByLeagueId(leagueId);
if (!wallet) {
const id = `wallet-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const newWallet: Wallet = {
@@ -57,29 +58,8 @@ export class GetWalletUseCase
const transactions = await this.transactionRepository.findByWalletId(wallet.id);
transactions.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
const dto: GetWalletResultDTO = {
wallet: {
id: wallet.id,
leagueId: wallet.leagueId,
balance: wallet.balance,
totalRevenue: wallet.totalRevenue,
totalPlatformFees: wallet.totalPlatformFees,
totalWithdrawn: wallet.totalWithdrawn,
currency: wallet.currency,
createdAt: wallet.createdAt,
},
transactions: transactions.map(t => ({
id: t.id,
walletId: t.walletId,
type: t.type,
amount: t.amount,
description: t.description,
referenceId: t.referenceId,
referenceType: t.referenceType,
createdAt: t.createdAt,
})),
};
this.output.present({ wallet, transactions });
presenter.present(dto);
return Result.ok(undefined);
}
}

View File

@@ -0,0 +1,114 @@
import { describe, it, expect, vi, type Mock } from 'vitest';
import { ProcessWalletTransactionUseCase, type ProcessWalletTransactionInput } from './ProcessWalletTransactionUseCase';
import type { IWalletRepository, ITransactionRepository } from '../../domain/repositories/IWalletRepository';
import { TransactionType, ReferenceType } from '../../domain/entities/Wallet';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
describe('ProcessWalletTransactionUseCase', () => {
let walletRepository: {
findByLeagueId: Mock;
create: Mock;
update: Mock;
};
let transactionRepository: {
create: Mock;
};
let output: {
present: Mock;
};
let useCase: ProcessWalletTransactionUseCase;
beforeEach(() => {
walletRepository = {
findByLeagueId: vi.fn(),
create: vi.fn(),
update: vi.fn(),
} as unknown as IWalletRepository as any;
transactionRepository = {
create: vi.fn(),
} as unknown as ITransactionRepository as any;
output = {
present: vi.fn(),
};
useCase = new ProcessWalletTransactionUseCase(
walletRepository as unknown as IWalletRepository,
transactionRepository as unknown as ITransactionRepository,
output as unknown as UseCaseOutputPort<any>,
);
});
it('processes a deposit transaction and presents the result', async () => {
const input: ProcessWalletTransactionInput = {
leagueId: 'league-1',
type: TransactionType.DEPOSIT,
amount: 100,
description: 'Test deposit',
referenceId: 'ref-1',
referenceType: ReferenceType.SPONSORSHIP,
};
const wallet = {
id: 'wallet-1',
leagueId: 'league-1',
balance: 50,
totalRevenue: 50,
totalPlatformFees: 0,
totalWithdrawn: 0,
currency: 'USD',
createdAt: new Date(),
};
const transaction = {
id: 'txn-1',
walletId: 'wallet-1',
type: TransactionType.DEPOSIT,
amount: 100,
description: 'Test deposit',
referenceId: 'ref-1',
referenceType: ReferenceType.SPONSORSHIP,
createdAt: new Date(),
};
walletRepository.findByLeagueId.mockResolvedValue(wallet);
transactionRepository.create.mockResolvedValue(transaction);
walletRepository.update.mockResolvedValue({ ...wallet, balance: 150, totalRevenue: 150 });
const result = await useCase.execute(input);
expect(result.isOk()).toBe(true);
expect(output.present).toHaveBeenCalledWith({
wallet: { ...wallet, balance: 150, totalRevenue: 150 },
transaction,
});
});
it('returns error for insufficient balance on withdrawal', async () => {
const input: ProcessWalletTransactionInput = {
leagueId: 'league-1',
type: TransactionType.WITHDRAWAL,
amount: 100,
description: 'Test withdrawal',
};
const wallet = {
id: 'wallet-1',
leagueId: 'league-1',
balance: 50,
totalRevenue: 50,
totalPlatformFees: 0,
totalWithdrawn: 0,
currency: 'USD',
createdAt: new Date(),
};
walletRepository.findByLeagueId.mockResolvedValue(wallet);
const result = await useCase.execute(input);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().code).toBe('INSUFFICIENT_BALANCE');
});
});

View File

@@ -5,13 +5,12 @@
*/
import type { IWalletRepository, ITransactionRepository } from '../../domain/repositories/IWalletRepository';
import type { Wallet, Transaction, TransactionType, ReferenceType } from '../../domain/entities/Wallet';
import type {
IProcessWalletTransactionPresenter,
ProcessWalletTransactionResultDTO,
ProcessWalletTransactionViewModel,
} from '../presenters/IProcessWalletTransactionPresenter';
import type { Wallet, Transaction } from '../../domain/entities/Wallet';
import { TransactionType, ReferenceType } from '../../domain/entities/Wallet';
import type { UseCase } from '@core/shared/application/UseCase';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
export interface ProcessWalletTransactionInput {
leagueId: string;
@@ -22,32 +21,35 @@ export interface ProcessWalletTransactionInput {
referenceType?: ReferenceType;
}
export interface ProcessWalletTransactionResult {
wallet: Wallet;
transaction: Transaction;
}
export type ProcessWalletTransactionErrorCode = 'MISSING_REQUIRED_FIELDS' | 'INVALID_TYPE' | 'INSUFFICIENT_BALANCE';
export class ProcessWalletTransactionUseCase
implements UseCase<ProcessWalletTransactionInput, ProcessWalletTransactionResultDTO, ProcessWalletTransactionViewModel, IProcessWalletTransactionPresenter>
implements UseCase<ProcessWalletTransactionInput, void, ProcessWalletTransactionErrorCode>
{
constructor(
private readonly walletRepository: IWalletRepository,
private readonly transactionRepository: ITransactionRepository,
private readonly output: UseCaseOutputPort<ProcessWalletTransactionResult>,
) {}
async execute(
input: ProcessWalletTransactionInput,
presenter: IProcessWalletTransactionPresenter,
): Promise<void> {
presenter.reset();
async execute(input: ProcessWalletTransactionInput): Promise<Result<void, ApplicationErrorCode<ProcessWalletTransactionErrorCode>>> {
const { leagueId, type, amount, description, referenceId, referenceType } = input;
if (!leagueId || !type || amount === undefined || !description) {
throw new Error('Missing required fields: leagueId, type, amount, description');
return Result.err({ code: 'MISSING_REQUIRED_FIELDS' as const });
}
if (type !== ('deposit' as TransactionType) && type !== ('withdrawal' as TransactionType)) {
throw new Error('Type must be "deposit" or "withdrawal"');
if (type !== TransactionType.DEPOSIT && type !== TransactionType.WITHDRAWAL) {
return Result.err({ code: 'INVALID_TYPE' as const });
}
let wallet = await this.walletRepository.findByLeagueId(leagueId);
if (!wallet) {
const id = `wallet-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const newWallet: Wallet = {
@@ -63,9 +65,9 @@ export class ProcessWalletTransactionUseCase
wallet = await this.walletRepository.create(newWallet);
}
if (type === ('withdrawal' as TransactionType)) {
if (type === TransactionType.WITHDRAWAL) {
if (amount > wallet.balance) {
throw new Error('Insufficient balance');
return Result.err({ code: 'INSUFFICIENT_BALANCE' as const });
}
}
@@ -76,14 +78,14 @@ export class ProcessWalletTransactionUseCase
type,
amount,
description,
referenceId,
referenceType,
createdAt: new Date(),
...(referenceId !== undefined ? { referenceId } : {}),
...(referenceType !== undefined ? { referenceType } : {}),
};
const createdTransaction = await this.transactionRepository.create(transaction);
if (type === ('deposit' as TransactionType)) {
if (type === TransactionType.DEPOSIT) {
wallet.balance += amount;
wallet.totalRevenue += amount;
} else {
@@ -93,29 +95,8 @@ export class ProcessWalletTransactionUseCase
const updatedWallet = await this.walletRepository.update(wallet);
const dto: ProcessWalletTransactionResultDTO = {
wallet: {
id: updatedWallet.id,
leagueId: updatedWallet.leagueId,
balance: updatedWallet.balance,
totalRevenue: updatedWallet.totalRevenue,
totalPlatformFees: updatedWallet.totalPlatformFees,
totalWithdrawn: updatedWallet.totalWithdrawn,
currency: updatedWallet.currency,
createdAt: updatedWallet.createdAt,
},
transaction: {
id: createdTransaction.id,
walletId: createdTransaction.walletId,
type: createdTransaction.type,
amount: createdTransaction.amount,
description: createdTransaction.description,
referenceId: createdTransaction.referenceId,
referenceType: createdTransaction.referenceType,
createdAt: createdTransaction.createdAt,
},
};
this.output.present({ wallet: updatedWallet, transaction: createdTransaction });
presenter.present(dto);
return Result.ok(undefined);
}
}

View File

@@ -5,13 +5,12 @@
*/
import type { IMembershipFeeRepository, IMemberPaymentRepository } from '../../domain/repositories/IMembershipFeeRepository';
import type { MemberPaymentStatus, MemberPayment } from '../../domain/entities/MemberPayment';
import type {
IUpdateMemberPaymentPresenter,
UpdateMemberPaymentResultDTO,
UpdateMemberPaymentViewModel,
} from '../presenters/IUpdateMemberPaymentPresenter';
import type { MemberPayment } from '../../domain/entities/MemberPayment';
import { MemberPaymentStatus } from '../../domain/entities/MemberPayment';
import type { UseCase } from '@core/shared/application/UseCase';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
const PLATFORM_FEE_RATE = 0.10;
@@ -22,25 +21,27 @@ export interface UpdateMemberPaymentInput {
paidAt?: Date | string;
}
export interface UpdateMemberPaymentResult {
payment: MemberPayment;
}
export type UpdateMemberPaymentErrorCode = 'MEMBERSHIP_FEE_NOT_FOUND';
export class UpdateMemberPaymentUseCase
implements UseCase<UpdateMemberPaymentInput, UpdateMemberPaymentResultDTO, UpdateMemberPaymentViewModel, IUpdateMemberPaymentPresenter>
implements UseCase<UpdateMemberPaymentInput, void, UpdateMemberPaymentErrorCode>
{
constructor(
private readonly membershipFeeRepository: IMembershipFeeRepository,
private readonly memberPaymentRepository: IMemberPaymentRepository,
private readonly output: UseCaseOutputPort<UpdateMemberPaymentResult>,
) {}
async execute(
input: UpdateMemberPaymentInput,
presenter: IUpdateMemberPaymentPresenter,
): Promise<void> {
presenter.reset();
async execute(input: UpdateMemberPaymentInput): Promise<Result<void, ApplicationErrorCode<UpdateMemberPaymentErrorCode>>> {
const { feeId, driverId, status, paidAt } = input;
const fee = await this.membershipFeeRepository.findById(feeId);
if (!fee) {
throw new Error('Membership fee configuration not found');
return Result.err({ code: 'MEMBERSHIP_FEE_NOT_FOUND' as const });
}
let payment = await this.memberPaymentRepository.findByFeeIdAndDriverId(feeId, driverId);
@@ -57,7 +58,7 @@ export class UpdateMemberPaymentUseCase
amount: fee.amount,
platformFee,
netAmount,
status: 'pending' as MemberPaymentStatus,
status: MemberPaymentStatus.PENDING,
dueDate: new Date(),
};
payment = await this.memberPaymentRepository.create(newPayment);
@@ -66,26 +67,14 @@ export class UpdateMemberPaymentUseCase
if (status) {
payment.status = status;
}
if (paidAt || status === ('paid' as MemberPaymentStatus)) {
if (paidAt || status === MemberPaymentStatus.PAID) {
payment.paidAt = paidAt ? new Date(paidAt as string) : new Date();
}
const updatedPayment = await this.memberPaymentRepository.update(payment);
const dto: UpdateMemberPaymentResultDTO = {
payment: {
id: updatedPayment.id,
feeId: updatedPayment.feeId,
driverId: updatedPayment.driverId,
amount: updatedPayment.amount,
platformFee: updatedPayment.platformFee,
netAmount: updatedPayment.netAmount,
status: updatedPayment.status,
dueDate: updatedPayment.dueDate,
paidAt: updatedPayment.paidAt,
},
};
this.output.present({ payment: updatedPayment });
presenter.present(dto);
return Result.ok(undefined);
}
}

View File

@@ -5,36 +5,38 @@
*/
import type { IPaymentRepository } from '../../domain/repositories/IPaymentRepository';
import type { PaymentStatus } from '../../domain/entities/Payment';
import type {
IUpdatePaymentStatusPresenter,
UpdatePaymentStatusResultDTO,
UpdatePaymentStatusViewModel,
PaymentDto,
} from '../presenters/IUpdatePaymentStatusPresenter';
import type { Payment } from '../../domain/entities/Payment';
import { PaymentStatus } from '../../domain/entities/Payment';
import type { UseCase } from '@core/shared/application/UseCase';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
export type UpdatePaymentStatusErrorCode = 'PAYMENT_NOT_FOUND';
export interface UpdatePaymentStatusInput {
paymentId: string;
status: PaymentStatus;
}
export interface UpdatePaymentStatusResult {
payment: Payment;
}
export class UpdatePaymentStatusUseCase
implements UseCase<UpdatePaymentStatusInput, UpdatePaymentStatusResultDTO, UpdatePaymentStatusViewModel, IUpdatePaymentStatusPresenter>
implements UseCase<UpdatePaymentStatusInput, void, UpdatePaymentStatusErrorCode>
{
constructor(private readonly paymentRepository: IPaymentRepository) {}
async execute(
input: UpdatePaymentStatusInput,
presenter: IUpdatePaymentStatusPresenter,
): Promise<void> {
presenter.reset();
constructor(
private readonly paymentRepository: IPaymentRepository,
private readonly output: UseCaseOutputPort<UpdatePaymentStatusResult>,
) {}
async execute(input: UpdatePaymentStatusInput): Promise<Result<void, ApplicationErrorCode<UpdatePaymentStatusErrorCode>>> {
const { paymentId, status } = input;
const existingPayment = await this.paymentRepository.findById(paymentId);
if (!existingPayment) {
throw new Error(`Payment with id ${paymentId} not found`);
return Result.err({ code: 'PAYMENT_NOT_FOUND' as const });
}
const updatedPayment = {
@@ -43,27 +45,10 @@ export class UpdatePaymentStatusUseCase
completedAt: status === PaymentStatus.COMPLETED ? new Date() : existingPayment.completedAt,
};
const savedPayment = await this.paymentRepository.update(updatedPayment);
const savedPayment = await this.paymentRepository.update(updatedPayment as Payment);
const dto: PaymentDto = {
id: savedPayment.id,
type: savedPayment.type,
amount: savedPayment.amount,
platformFee: savedPayment.platformFee,
netAmount: savedPayment.netAmount,
payerId: savedPayment.payerId,
payerType: savedPayment.payerType,
leagueId: savedPayment.leagueId,
seasonId: savedPayment.seasonId,
status: savedPayment.status,
createdAt: savedPayment.createdAt,
completedAt: savedPayment.completedAt,
};
this.output.present({ payment: savedPayment });
const result: UpdatePaymentStatusResultDTO = {
payment: dto,
};
presenter.present(result);
return Result.ok(undefined);
}
}

View File

@@ -6,12 +6,9 @@
import type { IMembershipFeeRepository } from '../../domain/repositories/IMembershipFeeRepository';
import type { MembershipFeeType, MembershipFee } from '../../domain/entities/MembershipFee';
import type {
IUpsertMembershipFeePresenter,
UpsertMembershipFeeResultDTO,
UpsertMembershipFeeViewModel,
} from '../presenters/IUpsertMembershipFeePresenter';
import type { UseCase } from '@core/shared/application/UseCase';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import { Result } from '@core/shared/application/Result';
export interface UpsertMembershipFeeInput {
leagueId: string;
@@ -20,26 +17,30 @@ export interface UpsertMembershipFeeInput {
amount: number;
}
export interface UpsertMembershipFeeResult {
fee: MembershipFee;
}
export type UpsertMembershipFeeErrorCode = never;
export class UpsertMembershipFeeUseCase
implements UseCase<UpsertMembershipFeeInput, UpsertMembershipFeeResultDTO, UpsertMembershipFeeViewModel, IUpsertMembershipFeePresenter>
implements UseCase<UpsertMembershipFeeInput, void, UpsertMembershipFeeErrorCode>
{
constructor(private readonly membershipFeeRepository: IMembershipFeeRepository) {}
async execute(
input: UpsertMembershipFeeInput,
presenter: IUpsertMembershipFeePresenter,
): Promise<void> {
presenter.reset();
constructor(
private readonly membershipFeeRepository: IMembershipFeeRepository,
private readonly output: UseCaseOutputPort<UpsertMembershipFeeResult>,
) {}
async execute(input: UpsertMembershipFeeInput): Promise<Result<void, never>> {
const { leagueId, seasonId, type, amount } = input;
let existingFee = await this.membershipFeeRepository.findByLeagueId(leagueId);
let fee: MembershipFee;
if (existingFee) {
existingFee.type = type;
existingFee.amount = amount;
existingFee.seasonId = seasonId;
if (seasonId !== undefined) existingFee.seasonId = seasonId;
existingFee.enabled = amount > 0;
existingFee.updatedAt = new Date();
fee = await this.membershipFeeRepository.update(existingFee);
@@ -48,29 +49,18 @@ export class UpsertMembershipFeeUseCase
const newFee: MembershipFee = {
id,
leagueId,
seasonId,
type,
amount,
enabled: amount > 0,
createdAt: new Date(),
updatedAt: new Date(),
...(seasonId !== undefined ? { seasonId } : {}),
};
fee = await this.membershipFeeRepository.create(newFee);
}
const dto: UpsertMembershipFeeResultDTO = {
fee: {
id: fee.id,
leagueId: fee.leagueId,
seasonId: fee.seasonId,
type: fee.type,
amount: fee.amount,
enabled: fee.enabled,
createdAt: fee.createdAt,
updatedAt: fee.updatedAt,
},
};
this.output.present({ fee });
presenter.present(dto);
return Result.ok(undefined);
}
}

View File

@@ -1,12 +0,0 @@
export * from './GetPaymentsUseCase';
export * from './CreatePaymentUseCase';
export * from './UpdatePaymentStatusUseCase';
export * from './GetMembershipFeesUseCase';
export * from './UpsertMembershipFeeUseCase';
export * from './UpdateMemberPaymentUseCase';
export * from './GetPrizesUseCase';
export * from './CreatePrizeUseCase';
export * from './AwardPrizeUseCase';
export * from './DeletePrizeUseCase';
export * from './GetWalletUseCase';
export * from './ProcessWalletTransactionUseCase';