239 lines
7.0 KiB
TypeScript
239 lines
7.0 KiB
TypeScript
/**
|
|
* Domain Entity: SponsorshipRequest
|
|
*
|
|
* Represents a sponsorship application from a Sponsor to any sponsorable entity
|
|
* (driver, team, race, or league/season). The entity owner must approve/reject.
|
|
*/
|
|
|
|
import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
|
|
import type { IEntity } from '@core/shared/domain';
|
|
|
|
import type { Money } from '../value-objects/Money';
|
|
import type { SponsorshipTier } from './season/SeasonSponsorship';
|
|
|
|
export type SponsorableEntityType = 'driver' | 'team' | 'race' | 'season';
|
|
export type SponsorshipRequestStatus = 'pending' | 'accepted' | 'rejected' | 'withdrawn';
|
|
|
|
export interface SponsorshipRequestProps {
|
|
id: string;
|
|
sponsorId: string;
|
|
entityType: SponsorableEntityType;
|
|
entityId: string;
|
|
tier: SponsorshipTier;
|
|
offeredAmount: Money;
|
|
message?: string;
|
|
status: SponsorshipRequestStatus;
|
|
createdAt: Date;
|
|
respondedAt?: Date;
|
|
respondedBy?: string; // driverId of the person who accepted/rejected
|
|
rejectionReason?: string;
|
|
}
|
|
|
|
export class SponsorshipRequest implements IEntity<string> {
|
|
readonly id: string;
|
|
readonly sponsorId: string;
|
|
readonly entityType: SponsorableEntityType;
|
|
readonly entityId: string;
|
|
readonly tier: SponsorshipTier;
|
|
readonly offeredAmount: Money;
|
|
readonly message: string | undefined;
|
|
readonly status: SponsorshipRequestStatus;
|
|
readonly createdAt: Date;
|
|
readonly respondedAt: Date | undefined;
|
|
readonly respondedBy: string | undefined;
|
|
readonly rejectionReason: string | undefined;
|
|
|
|
private constructor(props: SponsorshipRequestProps) {
|
|
this.id = props.id;
|
|
this.sponsorId = props.sponsorId;
|
|
this.entityType = props.entityType;
|
|
this.entityId = props.entityId;
|
|
this.tier = props.tier;
|
|
this.offeredAmount = props.offeredAmount;
|
|
this.message = props.message;
|
|
this.status = props.status;
|
|
this.createdAt = props.createdAt;
|
|
this.respondedAt = props.respondedAt;
|
|
this.respondedBy = props.respondedBy;
|
|
this.rejectionReason = props.rejectionReason;
|
|
}
|
|
|
|
static create(props: Omit<SponsorshipRequestProps, 'createdAt' | 'status'> & {
|
|
createdAt?: Date;
|
|
status?: SponsorshipRequestStatus;
|
|
}): SponsorshipRequest {
|
|
this.validate(props);
|
|
|
|
return new SponsorshipRequest({
|
|
...props,
|
|
createdAt: props.createdAt ?? new Date(),
|
|
status: props.status ?? 'pending',
|
|
});
|
|
}
|
|
|
|
private static validate(props: Omit<SponsorshipRequestProps, 'createdAt' | 'status'>): void {
|
|
if (!props.id || props.id.trim().length === 0) {
|
|
throw new RacingDomainValidationError('SponsorshipRequest ID is required');
|
|
}
|
|
|
|
if (!props.sponsorId || props.sponsorId.trim().length === 0) {
|
|
throw new RacingDomainValidationError('SponsorshipRequest sponsorId is required');
|
|
}
|
|
|
|
if (!props.entityType) {
|
|
throw new RacingDomainValidationError('SponsorshipRequest entityType is required');
|
|
}
|
|
|
|
if (!props.entityId || props.entityId.trim().length === 0) {
|
|
throw new RacingDomainValidationError('SponsorshipRequest entityId is required');
|
|
}
|
|
|
|
if (!props.tier) {
|
|
throw new RacingDomainValidationError('SponsorshipRequest tier is required');
|
|
}
|
|
|
|
if (!props.offeredAmount) {
|
|
throw new RacingDomainValidationError('SponsorshipRequest offeredAmount is required');
|
|
}
|
|
|
|
if (props.offeredAmount.amount <= 0) {
|
|
throw new RacingDomainValidationError('SponsorshipRequest offeredAmount must be greater than zero');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Accept the sponsorship request
|
|
*/
|
|
accept(respondedBy: string): SponsorshipRequest {
|
|
if (this.status !== 'pending') {
|
|
throw new RacingDomainInvariantError(`Cannot accept a ${this.status} sponsorship request`);
|
|
}
|
|
|
|
if (!respondedBy || respondedBy.trim().length === 0) {
|
|
throw new RacingDomainValidationError('respondedBy is required when accepting');
|
|
}
|
|
|
|
const base: SponsorshipRequestProps = {
|
|
id: this.id,
|
|
sponsorId: this.sponsorId,
|
|
entityType: this.entityType,
|
|
entityId: this.entityId,
|
|
tier: this.tier,
|
|
offeredAmount: this.offeredAmount,
|
|
status: 'accepted',
|
|
createdAt: this.createdAt,
|
|
respondedAt: new Date(),
|
|
respondedBy,
|
|
};
|
|
|
|
const withMessage =
|
|
this.message !== undefined ? { ...base, message: this.message } : base;
|
|
|
|
const next: SponsorshipRequestProps =
|
|
this.rejectionReason !== undefined
|
|
? { ...withMessage, rejectionReason: this.rejectionReason }
|
|
: withMessage;
|
|
|
|
return new SponsorshipRequest(next);
|
|
}
|
|
|
|
/**
|
|
* Reject the sponsorship request
|
|
*/
|
|
reject(respondedBy: string, reason?: string): SponsorshipRequest {
|
|
if (this.status !== 'pending') {
|
|
throw new RacingDomainInvariantError(`Cannot reject a ${this.status} sponsorship request`);
|
|
}
|
|
|
|
if (!respondedBy || respondedBy.trim().length === 0) {
|
|
throw new RacingDomainValidationError('respondedBy is required when rejecting');
|
|
}
|
|
|
|
const base: SponsorshipRequestProps = {
|
|
id: this.id,
|
|
sponsorId: this.sponsorId,
|
|
entityType: this.entityType,
|
|
entityId: this.entityId,
|
|
tier: this.tier,
|
|
offeredAmount: this.offeredAmount,
|
|
status: 'rejected',
|
|
createdAt: this.createdAt,
|
|
respondedAt: new Date(),
|
|
respondedBy,
|
|
};
|
|
|
|
const withMessage =
|
|
this.message !== undefined ? { ...base, message: this.message } : base;
|
|
|
|
const next: SponsorshipRequestProps =
|
|
reason !== undefined ? { ...withMessage, rejectionReason: reason } : withMessage;
|
|
|
|
return new SponsorshipRequest(next);
|
|
}
|
|
|
|
/**
|
|
* Withdraw the sponsorship request (by the sponsor)
|
|
*/
|
|
withdraw(): SponsorshipRequest {
|
|
if (this.status !== 'pending') {
|
|
throw new RacingDomainInvariantError(`Cannot withdraw a ${this.status} sponsorship request`);
|
|
}
|
|
|
|
const base: SponsorshipRequestProps = {
|
|
id: this.id,
|
|
sponsorId: this.sponsorId,
|
|
entityType: this.entityType,
|
|
entityId: this.entityId,
|
|
tier: this.tier,
|
|
offeredAmount: this.offeredAmount,
|
|
status: 'withdrawn',
|
|
createdAt: this.createdAt,
|
|
respondedAt: new Date(),
|
|
};
|
|
|
|
const withRespondedBy =
|
|
this.respondedBy !== undefined
|
|
? { ...base, respondedBy: this.respondedBy }
|
|
: base;
|
|
|
|
const withMessage =
|
|
this.message !== undefined
|
|
? { ...withRespondedBy, message: this.message }
|
|
: withRespondedBy;
|
|
|
|
const next: SponsorshipRequestProps =
|
|
this.rejectionReason !== undefined
|
|
? { ...withMessage, rejectionReason: this.rejectionReason }
|
|
: withMessage;
|
|
|
|
return new SponsorshipRequest(next);
|
|
}
|
|
|
|
/**
|
|
* Check if request is pending
|
|
*/
|
|
isPending(): boolean {
|
|
return this.status === 'pending';
|
|
}
|
|
|
|
/**
|
|
* Check if request was accepted
|
|
*/
|
|
isAccepted(): boolean {
|
|
return this.status === 'accepted';
|
|
}
|
|
|
|
/**
|
|
* Get platform fee for this request
|
|
*/
|
|
getPlatformFee(): Money {
|
|
return this.offeredAmount.calculatePlatformFee();
|
|
}
|
|
|
|
/**
|
|
* Get net amount after platform fee
|
|
*/
|
|
getNetAmount(): Money {
|
|
return this.offeredAmount.calculateNetAmount();
|
|
}
|
|
} |