diff --git a/packages/racing/application/ports/ILiveryCompositor.ts b/packages/racing/application/ports/ILiveryCompositor.ts new file mode 100644 index 000000000..d970c7e5f --- /dev/null +++ b/packages/racing/application/ports/ILiveryCompositor.ts @@ -0,0 +1,46 @@ +/** + * Application Port: ILiveryCompositor + * + * Defines interface for livery image composition. + * Infrastructure will provide image processing implementation. + */ + +import type { LiveryDecal } from '../../domain/value-objects/LiveryDecal'; + +export interface CompositionResult { + success: boolean; + composedImageUrl?: string; + error?: string; + timestamp: Date; +} + +export interface ILiveryCompositor { + /** + * Composite a livery by layering decals on base image + */ + composeLivery( + baseImageUrl: string, + decals: LiveryDecal[] + ): Promise; + + /** + * Generate a livery pack (.zip) for all drivers in a season + */ + generateLiveryPack( + seasonId: string, + liveryData: Array<{ + driverId: string; + driverName: string; + carId: string; + composedImageUrl: string; + }> + ): Promise; + + /** + * Validate livery image (check for logos/text) + */ + validateLiveryImage(imageUrl: string): Promise<{ + isValid: boolean; + violations?: string[]; + }>; +} \ No newline at end of file diff --git a/packages/racing/application/ports/ILiveryStorage.ts b/packages/racing/application/ports/ILiveryStorage.ts new file mode 100644 index 000000000..1e1c82c8d --- /dev/null +++ b/packages/racing/application/ports/ILiveryStorage.ts @@ -0,0 +1,39 @@ +/** + * Application Port: ILiveryStorage + * + * Defines interface for livery image storage. + * Infrastructure will provide cloud storage adapter. + */ + +export interface UploadResult { + success: boolean; + imageUrl?: string; + error?: string; + timestamp: Date; +} + +export interface ILiveryStorage { + /** + * Upload a livery image + */ + upload( + imageData: Buffer | string, + fileName: string, + metadata?: Record + ): Promise; + + /** + * Download a livery image + */ + download(imageUrl: string): Promise; + + /** + * Delete a livery image + */ + delete(imageUrl: string): Promise; + + /** + * Generate a signed URL for temporary access + */ + generateSignedUrl(imageUrl: string, expiresInSeconds: number): Promise; +} \ No newline at end of file diff --git a/packages/racing/application/ports/IPaymentGateway.ts b/packages/racing/application/ports/IPaymentGateway.ts new file mode 100644 index 000000000..7524dc47d --- /dev/null +++ b/packages/racing/application/ports/IPaymentGateway.ts @@ -0,0 +1,48 @@ +/** + * Application Port: IPaymentGateway + * + * Defines interface for payment processing. + * Infrastructure will provide mock or real implementation. + */ + +import type { Money } from '../../domain/value-objects/Money'; + +export interface PaymentResult { + success: boolean; + transactionId?: string; + error?: string; + timestamp: Date; +} + +export interface RefundResult { + success: boolean; + refundId?: string; + error?: string; + timestamp: Date; +} + +export interface IPaymentGateway { + /** + * Process a payment + */ + processPayment( + amount: Money, + payerId: string, + description: string, + metadata?: Record + ): Promise; + + /** + * Refund a payment + */ + refund( + originalTransactionId: string, + amount: Money, + reason: string + ): Promise; + + /** + * Verify payment status + */ + verifyPayment(transactionId: string): Promise; +} \ No newline at end of file diff --git a/packages/racing/domain/entities/DriverLivery.ts b/packages/racing/domain/entities/DriverLivery.ts new file mode 100644 index 000000000..e2c79c332 --- /dev/null +++ b/packages/racing/domain/entities/DriverLivery.ts @@ -0,0 +1,203 @@ +/** + * Domain Entity: DriverLivery + * + * Represents a driver's custom livery for a specific car. + * Includes user-placed decals and league-specific overrides. + */ + +import type { LiveryDecal } from '../value-objects/LiveryDecal'; + +export interface DecalOverride { + leagueId: string; + seasonId: string; + decalId: string; + newX: number; + newY: number; +} + +export interface DriverLiveryProps { + id: string; + driverId: string; + carId: string; + uploadedImageUrl: string; + userDecals: LiveryDecal[]; + leagueOverrides: DecalOverride[]; + createdAt: Date; + updatedAt?: Date; + validatedAt?: Date; +} + +export class DriverLivery { + readonly id: string; + readonly driverId: string; + readonly carId: string; + readonly uploadedImageUrl: string; + readonly userDecals: LiveryDecal[]; + readonly leagueOverrides: DecalOverride[]; + readonly createdAt: Date; + readonly updatedAt?: Date; + readonly validatedAt?: Date; + + private constructor(props: DriverLiveryProps) { + this.id = props.id; + this.driverId = props.driverId; + this.carId = props.carId; + this.uploadedImageUrl = props.uploadedImageUrl; + this.userDecals = props.userDecals; + this.leagueOverrides = props.leagueOverrides; + this.createdAt = props.createdAt; + this.updatedAt = props.updatedAt; + this.validatedAt = props.validatedAt; + } + + static create(props: Omit & { + createdAt?: Date; + userDecals?: LiveryDecal[]; + leagueOverrides?: DecalOverride[]; + }): DriverLivery { + this.validate(props); + + return new DriverLivery({ + ...props, + createdAt: props.createdAt ?? new Date(), + userDecals: props.userDecals ?? [], + leagueOverrides: props.leagueOverrides ?? [], + }); + } + + private static validate(props: Omit): void { + if (!props.id || props.id.trim().length === 0) { + throw new Error('DriverLivery ID is required'); + } + + if (!props.driverId || props.driverId.trim().length === 0) { + throw new Error('DriverLivery driverId is required'); + } + + if (!props.carId || props.carId.trim().length === 0) { + throw new Error('DriverLivery carId is required'); + } + + if (!props.uploadedImageUrl || props.uploadedImageUrl.trim().length === 0) { + throw new Error('DriverLivery uploadedImageUrl is required'); + } + } + + /** + * Add a user decal + */ + addDecal(decal: LiveryDecal): DriverLivery { + if (decal.type !== 'user') { + throw new Error('Only user decals can be added to driver livery'); + } + + return new DriverLivery({ + ...this, + userDecals: [...this.userDecals, decal], + updatedAt: new Date(), + }); + } + + /** + * Remove a user decal + */ + removeDecal(decalId: string): DriverLivery { + const updatedDecals = this.userDecals.filter(d => d.id !== decalId); + + if (updatedDecals.length === this.userDecals.length) { + throw new Error('Decal not found in livery'); + } + + return new DriverLivery({ + ...this, + userDecals: updatedDecals, + updatedAt: new Date(), + }); + } + + /** + * Update a user decal + */ + updateDecal(decalId: string, updatedDecal: LiveryDecal): DriverLivery { + const index = this.userDecals.findIndex(d => d.id === decalId); + + if (index === -1) { + throw new Error('Decal not found in livery'); + } + + const updatedDecals = [...this.userDecals]; + updatedDecals[index] = updatedDecal; + + return new DriverLivery({ + ...this, + userDecals: updatedDecals, + updatedAt: new Date(), + }); + } + + /** + * Add or update a league-specific decal override + */ + setLeagueOverride(leagueId: string, seasonId: string, decalId: string, newX: number, newY: number): DriverLivery { + const existingIndex = this.leagueOverrides.findIndex( + o => o.leagueId === leagueId && o.seasonId === seasonId && o.decalId === decalId + ); + + const override: DecalOverride = { leagueId, seasonId, decalId, newX, newY }; + + let updatedOverrides: DecalOverride[]; + if (existingIndex >= 0) { + updatedOverrides = [...this.leagueOverrides]; + updatedOverrides[existingIndex] = override; + } else { + updatedOverrides = [...this.leagueOverrides, override]; + } + + return new DriverLivery({ + ...this, + leagueOverrides: updatedOverrides, + updatedAt: new Date(), + }); + } + + /** + * Remove a league-specific override + */ + removeLeagueOverride(leagueId: string, seasonId: string, decalId: string): DriverLivery { + const updatedOverrides = this.leagueOverrides.filter( + o => !(o.leagueId === leagueId && o.seasonId === seasonId && o.decalId === decalId) + ); + + return new DriverLivery({ + ...this, + leagueOverrides: updatedOverrides, + updatedAt: new Date(), + }); + } + + /** + * Get overrides for a specific league/season + */ + getOverridesFor(leagueId: string, seasonId: string): DecalOverride[] { + return this.leagueOverrides.filter( + o => o.leagueId === leagueId && o.seasonId === seasonId + ); + } + + /** + * Mark livery as validated (no logos/text detected) + */ + markAsValidated(): DriverLivery { + return new DriverLivery({ + ...this, + validatedAt: new Date(), + }); + } + + /** + * Check if livery is validated + */ + isValidated(): boolean { + return this.validatedAt !== undefined; + } +} \ No newline at end of file diff --git a/packages/racing/domain/entities/LeagueWallet.ts b/packages/racing/domain/entities/LeagueWallet.ts new file mode 100644 index 000000000..901d560c4 --- /dev/null +++ b/packages/racing/domain/entities/LeagueWallet.ts @@ -0,0 +1,123 @@ +/** + * Domain Entity: LeagueWallet + * + * Represents a league's financial wallet. + * Aggregate root for managing league finances and transactions. + */ + +import type { Money } from '../value-objects/Money'; +import type { Transaction } from './Transaction'; + +export interface LeagueWalletProps { + id: string; + leagueId: string; + balance: Money; + transactionIds: string[]; + createdAt: Date; +} + +export class LeagueWallet { + readonly id: string; + readonly leagueId: string; + readonly balance: Money; + readonly transactionIds: string[]; + readonly createdAt: Date; + + private constructor(props: LeagueWalletProps) { + this.id = props.id; + this.leagueId = props.leagueId; + this.balance = props.balance; + this.transactionIds = props.transactionIds; + this.createdAt = props.createdAt; + } + + static create(props: Omit & { + createdAt?: Date; + transactionIds?: string[]; + }): LeagueWallet { + this.validate(props); + + return new LeagueWallet({ + ...props, + createdAt: props.createdAt ?? new Date(), + transactionIds: props.transactionIds ?? [], + }); + } + + private static validate(props: Omit): void { + if (!props.id || props.id.trim().length === 0) { + throw new Error('LeagueWallet ID is required'); + } + + if (!props.leagueId || props.leagueId.trim().length === 0) { + throw new Error('LeagueWallet leagueId is required'); + } + + if (!props.balance) { + throw new Error('LeagueWallet balance is required'); + } + } + + /** + * Add funds to wallet (from sponsorship or membership payments) + */ + addFunds(netAmount: Money, transactionId: string): LeagueWallet { + if (this.balance.currency !== netAmount.currency) { + throw new Error('Cannot add funds with different currency'); + } + + const newBalance = this.balance.add(netAmount); + + return new LeagueWallet({ + ...this, + balance: newBalance, + transactionIds: [...this.transactionIds, transactionId], + }); + } + + /** + * Withdraw funds from wallet + * Domain rule: Cannot withdraw if insufficient balance + */ + withdrawFunds(amount: Money, transactionId: string): LeagueWallet { + if (this.balance.currency !== amount.currency) { + throw new Error('Cannot withdraw funds with different currency'); + } + + if (!this.balance.isGreaterThan(amount) && !this.balance.equals(amount)) { + throw new Error('Insufficient balance for withdrawal'); + } + + const newBalance = this.balance.subtract(amount); + + return new LeagueWallet({ + ...this, + balance: newBalance, + transactionIds: [...this.transactionIds, transactionId], + }); + } + + /** + * Check if wallet can withdraw a specific amount + */ + canWithdraw(amount: Money): boolean { + if (this.balance.currency !== amount.currency) { + return false; + } + return this.balance.isGreaterThan(amount) || this.balance.equals(amount); + } + + /** + * Get current balance + */ + getBalance(): Money { + return this.balance; + } + + /** + * Get all transaction IDs + */ + getTransactionIds(): string[] { + return [...this.transactionIds]; + } +} \ No newline at end of file diff --git a/packages/racing/domain/entities/LiveryTemplate.ts b/packages/racing/domain/entities/LiveryTemplate.ts new file mode 100644 index 000000000..d601ffbd7 --- /dev/null +++ b/packages/racing/domain/entities/LiveryTemplate.ts @@ -0,0 +1,142 @@ +/** + * Domain Entity: LiveryTemplate + * + * Represents an admin-defined livery template for a specific car. + * Contains base image and sponsor decal placements. + */ + +import type { LiveryDecal } from '../value-objects/LiveryDecal'; + +export interface LiveryTemplateProps { + id: string; + leagueId: string; + seasonId: string; + carId: string; + baseImageUrl: string; + adminDecals: LiveryDecal[]; + createdAt: Date; + updatedAt?: Date; +} + +export class LiveryTemplate { + readonly id: string; + readonly leagueId: string; + readonly seasonId: string; + readonly carId: string; + readonly baseImageUrl: string; + readonly adminDecals: LiveryDecal[]; + readonly createdAt: Date; + readonly updatedAt?: Date; + + private constructor(props: LiveryTemplateProps) { + this.id = props.id; + this.leagueId = props.leagueId; + this.seasonId = props.seasonId; + this.carId = props.carId; + this.baseImageUrl = props.baseImageUrl; + this.adminDecals = props.adminDecals; + this.createdAt = props.createdAt; + this.updatedAt = props.updatedAt; + } + + static create(props: Omit & { + createdAt?: Date; + adminDecals?: LiveryDecal[]; + }): LiveryTemplate { + this.validate(props); + + return new LiveryTemplate({ + ...props, + createdAt: props.createdAt ?? new Date(), + adminDecals: props.adminDecals ?? [], + }); + } + + private static validate(props: Omit): void { + if (!props.id || props.id.trim().length === 0) { + throw new Error('LiveryTemplate ID is required'); + } + + if (!props.leagueId || props.leagueId.trim().length === 0) { + throw new Error('LiveryTemplate leagueId is required'); + } + + if (!props.seasonId || props.seasonId.trim().length === 0) { + throw new Error('LiveryTemplate seasonId is required'); + } + + if (!props.carId || props.carId.trim().length === 0) { + throw new Error('LiveryTemplate carId is required'); + } + + if (!props.baseImageUrl || props.baseImageUrl.trim().length === 0) { + throw new Error('LiveryTemplate baseImageUrl is required'); + } + } + + /** + * Add a decal to the template + */ + addDecal(decal: LiveryDecal): LiveryTemplate { + if (decal.type !== 'sponsor') { + throw new Error('Only sponsor decals can be added to admin template'); + } + + return new LiveryTemplate({ + ...this, + adminDecals: [...this.adminDecals, decal], + updatedAt: new Date(), + }); + } + + /** + * Remove a decal from the template + */ + removeDecal(decalId: string): LiveryTemplate { + const updatedDecals = this.adminDecals.filter(d => d.id !== decalId); + + if (updatedDecals.length === this.adminDecals.length) { + throw new Error('Decal not found in template'); + } + + return new LiveryTemplate({ + ...this, + adminDecals: updatedDecals, + updatedAt: new Date(), + }); + } + + /** + * Update a decal position + */ + updateDecal(decalId: string, updatedDecal: LiveryDecal): LiveryTemplate { + const index = this.adminDecals.findIndex(d => d.id === decalId); + + if (index === -1) { + throw new Error('Decal not found in template'); + } + + const updatedDecals = [...this.adminDecals]; + updatedDecals[index] = updatedDecal; + + return new LiveryTemplate({ + ...this, + adminDecals: updatedDecals, + updatedAt: new Date(), + }); + } + + /** + * Get all sponsor decals + */ + getSponsorDecals(): LiveryDecal[] { + return this.adminDecals.filter(d => d.type === 'sponsor'); + } + + /** + * Check if template has sponsor decals + */ + hasSponsorDecals(): boolean { + return this.adminDecals.some(d => d.type === 'sponsor'); + } +} \ No newline at end of file diff --git a/packages/racing/domain/entities/Prize.ts b/packages/racing/domain/entities/Prize.ts new file mode 100644 index 000000000..01a6aca33 --- /dev/null +++ b/packages/racing/domain/entities/Prize.ts @@ -0,0 +1,157 @@ +/** + * Domain Entity: Prize + * + * Represents a prize awarded to a driver for a specific position in a season. + */ + +import type { Money } from '../value-objects/Money'; + +export type PrizeStatus = 'pending' | 'awarded' | 'paid' | 'cancelled'; + +export interface PrizeProps { + id: string; + seasonId: string; + position: number; + amount: Money; + driverId?: string; + status: PrizeStatus; + createdAt: Date; + awardedAt?: Date; + paidAt?: Date; + description?: string; +} + +export class Prize { + readonly id: string; + readonly seasonId: string; + readonly position: number; + readonly amount: Money; + readonly driverId?: string; + readonly status: PrizeStatus; + readonly createdAt: Date; + readonly awardedAt?: Date; + readonly paidAt?: Date; + readonly description?: string; + + private constructor(props: PrizeProps) { + this.id = props.id; + this.seasonId = props.seasonId; + this.position = props.position; + this.amount = props.amount; + this.driverId = props.driverId; + this.status = props.status; + this.createdAt = props.createdAt; + this.awardedAt = props.awardedAt; + this.paidAt = props.paidAt; + this.description = props.description; + } + + static create(props: Omit & { + createdAt?: Date; + status?: PrizeStatus; + }): Prize { + this.validate(props); + + return new Prize({ + ...props, + createdAt: props.createdAt ?? new Date(), + status: props.status ?? 'pending', + }); + } + + private static validate(props: Omit): void { + if (!props.id || props.id.trim().length === 0) { + throw new Error('Prize ID is required'); + } + + if (!props.seasonId || props.seasonId.trim().length === 0) { + throw new Error('Prize seasonId is required'); + } + + if (!Number.isInteger(props.position) || props.position < 1) { + throw new Error('Prize position must be a positive integer'); + } + + if (!props.amount) { + throw new Error('Prize amount is required'); + } + + if (props.amount.amount <= 0) { + throw new Error('Prize amount must be greater than zero'); + } + } + + /** + * Award prize to a driver + */ + awardTo(driverId: string): Prize { + if (!driverId || driverId.trim().length === 0) { + throw new Error('Driver ID is required to award prize'); + } + + if (this.status !== 'pending') { + throw new Error('Only pending prizes can be awarded'); + } + + return new Prize({ + ...this, + driverId, + status: 'awarded', + awardedAt: new Date(), + }); + } + + /** + * Mark prize as paid + */ + markAsPaid(): Prize { + if (this.status !== 'awarded') { + throw new Error('Only awarded prizes can be marked as paid'); + } + + if (!this.driverId) { + throw new Error('Prize must have a driver to be paid'); + } + + return new Prize({ + ...this, + status: 'paid', + paidAt: new Date(), + }); + } + + /** + * Cancel prize + */ + cancel(): Prize { + if (this.status === 'paid') { + throw new Error('Cannot cancel a paid prize'); + } + + return new Prize({ + ...this, + status: 'cancelled', + }); + } + + /** + * Check if prize is pending + */ + isPending(): boolean { + return this.status === 'pending'; + } + + /** + * Check if prize is awarded + */ + isAwarded(): boolean { + return this.status === 'awarded'; + } + + /** + * Check if prize is paid + */ + isPaid(): boolean { + return this.status === 'paid'; + } +} \ No newline at end of file diff --git a/packages/racing/domain/entities/Season.ts b/packages/racing/domain/entities/Season.ts index 4de3c53f9..97fe9bd65 100644 --- a/packages/racing/domain/entities/Season.ts +++ b/packages/racing/domain/entities/Season.ts @@ -74,4 +74,25 @@ export class Season { endDate: props.endDate, }); } + + /** + * Domain rule: Wallet withdrawals are only allowed when season is completed + */ + canWithdrawFromWallet(): boolean { + return this.status === 'completed'; + } + + /** + * Check if season is active + */ + isActive(): boolean { + return this.status === 'active'; + } + + /** + * Check if season is completed + */ + isCompleted(): boolean { + return this.status === 'completed'; + } } \ No newline at end of file diff --git a/packages/racing/domain/entities/SeasonSponsorship.ts b/packages/racing/domain/entities/SeasonSponsorship.ts new file mode 100644 index 000000000..35f3c4e5b --- /dev/null +++ b/packages/racing/domain/entities/SeasonSponsorship.ts @@ -0,0 +1,140 @@ +/** + * Domain Entity: SeasonSponsorship + * + * Represents a sponsorship relationship between a Sponsor and a Season. + * Aggregate root for managing sponsorship slots and pricing. + */ + +import type { Money } from '../value-objects/Money'; + +export type SponsorshipTier = 'main' | 'secondary'; +export type SponsorshipStatus = 'pending' | 'active' | 'cancelled'; + +export interface SeasonSponsorshipProps { + id: string; + seasonId: string; + sponsorId: string; + tier: SponsorshipTier; + pricing: Money; + status: SponsorshipStatus; + createdAt: Date; + activatedAt?: Date; + description?: string; +} + +export class SeasonSponsorship { + readonly id: string; + readonly seasonId: string; + readonly sponsorId: string; + readonly tier: SponsorshipTier; + readonly pricing: Money; + readonly status: SponsorshipStatus; + readonly createdAt: Date; + readonly activatedAt?: Date; + readonly description?: string; + + private constructor(props: SeasonSponsorshipProps) { + this.id = props.id; + this.seasonId = props.seasonId; + this.sponsorId = props.sponsorId; + this.tier = props.tier; + this.pricing = props.pricing; + this.status = props.status; + this.createdAt = props.createdAt; + this.activatedAt = props.activatedAt; + this.description = props.description; + } + + static create(props: Omit & { + createdAt?: Date; + status?: SponsorshipStatus; + }): SeasonSponsorship { + this.validate(props); + + return new SeasonSponsorship({ + ...props, + createdAt: props.createdAt ?? new Date(), + status: props.status ?? 'pending', + }); + } + + private static validate(props: Omit): void { + if (!props.id || props.id.trim().length === 0) { + throw new Error('SeasonSponsorship ID is required'); + } + + if (!props.seasonId || props.seasonId.trim().length === 0) { + throw new Error('SeasonSponsorship seasonId is required'); + } + + if (!props.sponsorId || props.sponsorId.trim().length === 0) { + throw new Error('SeasonSponsorship sponsorId is required'); + } + + if (!props.tier) { + throw new Error('SeasonSponsorship tier is required'); + } + + if (!props.pricing) { + throw new Error('SeasonSponsorship pricing is required'); + } + + if (props.pricing.amount <= 0) { + throw new Error('SeasonSponsorship pricing must be greater than zero'); + } + } + + /** + * Activate the sponsorship + */ + activate(): SeasonSponsorship { + if (this.status === 'active') { + throw new Error('SeasonSponsorship is already active'); + } + + if (this.status === 'cancelled') { + throw new Error('Cannot activate a cancelled SeasonSponsorship'); + } + + return new SeasonSponsorship({ + ...this, + status: 'active', + activatedAt: new Date(), + }); + } + + /** + * Cancel the sponsorship + */ + cancel(): SeasonSponsorship { + if (this.status === 'cancelled') { + throw new Error('SeasonSponsorship is already cancelled'); + } + + return new SeasonSponsorship({ + ...this, + status: 'cancelled', + }); + } + + /** + * Check if sponsorship is active + */ + isActive(): boolean { + return this.status === 'active'; + } + + /** + * Get the platform fee for this sponsorship + */ + getPlatformFee(): Money { + return this.pricing.calculatePlatformFee(); + } + + /** + * Get the net amount after platform fee + */ + getNetAmount(): Money { + return this.pricing.calculateNetAmount(); + } +} \ No newline at end of file diff --git a/packages/racing/domain/entities/Sponsor.ts b/packages/racing/domain/entities/Sponsor.ts new file mode 100644 index 000000000..1852e8ce1 --- /dev/null +++ b/packages/racing/domain/entities/Sponsor.ts @@ -0,0 +1,96 @@ +/** + * Domain Entity: Sponsor + * + * Represents a sponsor that can sponsor leagues/seasons. + * Aggregate root for sponsor information. + */ + +export interface SponsorProps { + id: string; + name: string; + contactEmail: string; + logoUrl?: string; + websiteUrl?: string; + createdAt: Date; +} + +export class Sponsor { + readonly id: string; + readonly name: string; + readonly contactEmail: string; + readonly logoUrl?: string; + readonly websiteUrl?: string; + readonly createdAt: Date; + + private constructor(props: SponsorProps) { + this.id = props.id; + this.name = props.name; + this.contactEmail = props.contactEmail; + this.logoUrl = props.logoUrl; + this.websiteUrl = props.websiteUrl; + this.createdAt = props.createdAt; + } + + static create(props: Omit & { createdAt?: Date }): Sponsor { + this.validate(props); + + return new Sponsor({ + ...props, + createdAt: props.createdAt ?? new Date(), + }); + } + + private static validate(props: Omit): void { + if (!props.id || props.id.trim().length === 0) { + throw new Error('Sponsor ID is required'); + } + + if (!props.name || props.name.trim().length === 0) { + throw new Error('Sponsor name is required'); + } + + if (props.name.length > 100) { + throw new Error('Sponsor name must be 100 characters or less'); + } + + if (!props.contactEmail || props.contactEmail.trim().length === 0) { + throw new Error('Sponsor contact email is required'); + } + + // Basic email validation + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(props.contactEmail)) { + throw new Error('Invalid sponsor contact email format'); + } + + if (props.websiteUrl && props.websiteUrl.trim().length > 0) { + try { + new URL(props.websiteUrl); + } catch { + throw new Error('Invalid sponsor website URL'); + } + } + } + + /** + * Update sponsor information + */ + update(props: Partial<{ + name: string; + contactEmail: string; + logoUrl: string | undefined; + websiteUrl: string | undefined; + }>): Sponsor { + const updated = { + id: this.id, + name: props.name ?? this.name, + contactEmail: props.contactEmail ?? this.contactEmail, + logoUrl: props.logoUrl !== undefined ? props.logoUrl : this.logoUrl, + websiteUrl: props.websiteUrl !== undefined ? props.websiteUrl : this.websiteUrl, + createdAt: this.createdAt, + }; + + Sponsor.validate(updated); + return new Sponsor(updated); + } +} \ No newline at end of file diff --git a/packages/racing/domain/entities/Transaction.ts b/packages/racing/domain/entities/Transaction.ts new file mode 100644 index 000000000..0abc14e2d --- /dev/null +++ b/packages/racing/domain/entities/Transaction.ts @@ -0,0 +1,159 @@ +/** + * Domain Entity: Transaction + * + * Represents a financial transaction in the league wallet system. + */ + +import type { Money } from '../value-objects/Money'; + +export type TransactionType = + | 'sponsorship_payment' + | 'membership_payment' + | 'prize_payout' + | 'withdrawal' + | 'refund'; + +export type TransactionStatus = 'pending' | 'completed' | 'failed' | 'cancelled'; + +export interface TransactionProps { + id: string; + walletId: string; + type: TransactionType; + amount: Money; + platformFee: Money; + netAmount: Money; + status: TransactionStatus; + createdAt: Date; + completedAt?: Date; + description?: string; + metadata?: Record; +} + +export class Transaction { + readonly id: string; + readonly walletId: string; + readonly type: TransactionType; + readonly amount: Money; + readonly platformFee: Money; + readonly netAmount: Money; + readonly status: TransactionStatus; + readonly createdAt: Date; + readonly completedAt?: Date; + readonly description?: string; + readonly metadata?: Record; + + private constructor(props: TransactionProps) { + this.id = props.id; + this.walletId = props.walletId; + this.type = props.type; + this.amount = props.amount; + this.platformFee = props.platformFee; + this.netAmount = props.netAmount; + this.status = props.status; + this.createdAt = props.createdAt; + this.completedAt = props.completedAt; + this.description = props.description; + this.metadata = props.metadata; + } + + static create(props: Omit & { + createdAt?: Date; + status?: TransactionStatus; + }): Transaction { + this.validate(props); + + const platformFee = props.amount.calculatePlatformFee(); + const netAmount = props.amount.calculateNetAmount(); + + return new Transaction({ + ...props, + platformFee, + netAmount, + createdAt: props.createdAt ?? new Date(), + status: props.status ?? 'pending', + }); + } + + private static validate(props: Omit): void { + if (!props.id || props.id.trim().length === 0) { + throw new Error('Transaction ID is required'); + } + + if (!props.walletId || props.walletId.trim().length === 0) { + throw new Error('Transaction walletId is required'); + } + + if (!props.type) { + throw new Error('Transaction type is required'); + } + + if (!props.amount) { + throw new Error('Transaction amount is required'); + } + + if (props.amount.amount <= 0) { + throw new Error('Transaction amount must be greater than zero'); + } + } + + /** + * Mark transaction as completed + */ + complete(): Transaction { + if (this.status === 'completed') { + throw new Error('Transaction is already completed'); + } + + if (this.status === 'failed' || this.status === 'cancelled') { + throw new Error('Cannot complete a failed or cancelled transaction'); + } + + return new Transaction({ + ...this, + status: 'completed', + completedAt: new Date(), + }); + } + + /** + * Mark transaction as failed + */ + fail(): Transaction { + if (this.status === 'completed') { + throw new Error('Cannot fail a completed transaction'); + } + + return new Transaction({ + ...this, + status: 'failed', + }); + } + + /** + * Cancel transaction + */ + cancel(): Transaction { + if (this.status === 'completed') { + throw new Error('Cannot cancel a completed transaction'); + } + + return new Transaction({ + ...this, + status: 'cancelled', + }); + } + + /** + * Check if transaction is completed + */ + isCompleted(): boolean { + return this.status === 'completed'; + } + + /** + * Check if transaction is pending + */ + isPending(): boolean { + return this.status === 'pending'; + } +} \ No newline at end of file diff --git a/packages/racing/domain/repositories/ILeagueWalletRepository.ts b/packages/racing/domain/repositories/ILeagueWalletRepository.ts new file mode 100644 index 000000000..34cac6f1d --- /dev/null +++ b/packages/racing/domain/repositories/ILeagueWalletRepository.ts @@ -0,0 +1,16 @@ +/** + * Repository Interface: ILeagueWalletRepository + * + * Defines operations for LeagueWallet aggregate persistence + */ + +import type { LeagueWallet } from '../entities/LeagueWallet'; + +export interface ILeagueWalletRepository { + findById(id: string): Promise; + findByLeagueId(leagueId: string): Promise; + create(wallet: LeagueWallet): Promise; + update(wallet: LeagueWallet): Promise; + delete(id: string): Promise; + exists(id: string): Promise; +} \ No newline at end of file diff --git a/packages/racing/domain/repositories/ILiveryRepository.ts b/packages/racing/domain/repositories/ILiveryRepository.ts new file mode 100644 index 000000000..8017dd7d7 --- /dev/null +++ b/packages/racing/domain/repositories/ILiveryRepository.ts @@ -0,0 +1,26 @@ +/** + * Repository Interface: ILiveryRepository + * + * Defines operations for livery-related entities + */ + +import type { DriverLivery } from '../entities/DriverLivery'; +import type { LiveryTemplate } from '../entities/LiveryTemplate'; + +export interface ILiveryRepository { + // DriverLivery operations + findDriverLiveryById(id: string): Promise; + findDriverLiveriesByDriverId(driverId: string): Promise; + findDriverLiveryByDriverAndCar(driverId: string, carId: string): Promise; + createDriverLivery(livery: DriverLivery): Promise; + updateDriverLivery(livery: DriverLivery): Promise; + deleteDriverLivery(id: string): Promise; + + // LiveryTemplate operations + findTemplateById(id: string): Promise; + findTemplatesBySeasonId(seasonId: string): Promise; + findTemplateBySeasonAndCar(seasonId: string, carId: string): Promise; + createTemplate(template: LiveryTemplate): Promise; + updateTemplate(template: LiveryTemplate): Promise; + deleteTemplate(id: string): Promise; +} \ No newline at end of file diff --git a/packages/racing/domain/repositories/IPrizeRepository.ts b/packages/racing/domain/repositories/IPrizeRepository.ts new file mode 100644 index 000000000..0142932e3 --- /dev/null +++ b/packages/racing/domain/repositories/IPrizeRepository.ts @@ -0,0 +1,19 @@ +/** + * Repository Interface: IPrizeRepository + * + * Defines operations for Prize entity persistence + */ + +import type { Prize, PrizeStatus } from '../entities/Prize'; + +export interface IPrizeRepository { + findById(id: string): Promise; + findBySeasonId(seasonId: string): Promise; + findByDriverId(driverId: string): Promise; + findByStatus(status: PrizeStatus): Promise; + findBySeasonAndPosition(seasonId: string, position: number): Promise; + create(prize: Prize): Promise; + update(prize: Prize): Promise; + delete(id: string): Promise; + exists(id: string): Promise; +} \ No newline at end of file diff --git a/packages/racing/domain/repositories/ISeasonSponsorshipRepository.ts b/packages/racing/domain/repositories/ISeasonSponsorshipRepository.ts new file mode 100644 index 000000000..717650a64 --- /dev/null +++ b/packages/racing/domain/repositories/ISeasonSponsorshipRepository.ts @@ -0,0 +1,18 @@ +/** + * Repository Interface: ISeasonSponsorshipRepository + * + * Defines operations for SeasonSponsorship aggregate persistence + */ + +import type { SeasonSponsorship, SponsorshipTier } from '../entities/SeasonSponsorship'; + +export interface ISeasonSponsorshipRepository { + findById(id: string): Promise; + findBySeasonId(seasonId: string): Promise; + findBySponsorId(sponsorId: string): Promise; + findBySeasonAndTier(seasonId: string, tier: SponsorshipTier): Promise; + create(sponsorship: SeasonSponsorship): Promise; + update(sponsorship: SeasonSponsorship): Promise; + delete(id: string): Promise; + exists(id: string): Promise; +} \ No newline at end of file diff --git a/packages/racing/domain/repositories/ISponsorRepository.ts b/packages/racing/domain/repositories/ISponsorRepository.ts new file mode 100644 index 000000000..97a0bf4d3 --- /dev/null +++ b/packages/racing/domain/repositories/ISponsorRepository.ts @@ -0,0 +1,17 @@ +/** + * Repository Interface: ISponsorRepository + * + * Defines operations for Sponsor aggregate persistence + */ + +import type { Sponsor } from '../entities/Sponsor'; + +export interface ISponsorRepository { + findById(id: string): Promise; + findAll(): Promise; + findByEmail(email: string): Promise; + create(sponsor: Sponsor): Promise; + update(sponsor: Sponsor): Promise; + delete(id: string): Promise; + exists(id: string): Promise; +} \ No newline at end of file diff --git a/packages/racing/domain/repositories/ITransactionRepository.ts b/packages/racing/domain/repositories/ITransactionRepository.ts new file mode 100644 index 000000000..8342a55da --- /dev/null +++ b/packages/racing/domain/repositories/ITransactionRepository.ts @@ -0,0 +1,17 @@ +/** + * Repository Interface: ITransactionRepository + * + * Defines operations for Transaction entity persistence + */ + +import type { Transaction, TransactionType } from '../entities/Transaction'; + +export interface ITransactionRepository { + findById(id: string): Promise; + findByWalletId(walletId: string): Promise; + findByType(type: TransactionType): Promise; + create(transaction: Transaction): Promise; + update(transaction: Transaction): Promise; + delete(id: string): Promise; + exists(id: string): Promise; +} \ No newline at end of file diff --git a/packages/racing/domain/value-objects/LiveryDecal.ts b/packages/racing/domain/value-objects/LiveryDecal.ts new file mode 100644 index 000000000..5bfa567f0 --- /dev/null +++ b/packages/racing/domain/value-objects/LiveryDecal.ts @@ -0,0 +1,127 @@ +/** + * Value Object: LiveryDecal + * Represents a decal/logo placed on a livery + */ + +export type DecalType = 'sponsor' | 'user'; + +export interface LiveryDecalProps { + id: string; + imageUrl: string; + x: number; + y: number; + width: number; + height: number; + zIndex: number; + type: DecalType; +} + +export class LiveryDecal { + readonly id: string; + readonly imageUrl: string; + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; + readonly zIndex: number; + readonly type: DecalType; + + private constructor(props: LiveryDecalProps) { + this.id = props.id; + this.imageUrl = props.imageUrl; + this.x = props.x; + this.y = props.y; + this.width = props.width; + this.height = props.height; + this.zIndex = props.zIndex; + this.type = props.type; + } + + static create(props: LiveryDecalProps): LiveryDecal { + this.validate(props); + return new LiveryDecal(props); + } + + private static validate(props: LiveryDecalProps): void { + if (!props.id || props.id.trim().length === 0) { + throw new Error('LiveryDecal ID is required'); + } + + if (!props.imageUrl || props.imageUrl.trim().length === 0) { + throw new Error('LiveryDecal imageUrl is required'); + } + + if (props.x < 0 || props.x > 1) { + throw new Error('LiveryDecal x coordinate must be between 0 and 1 (normalized)'); + } + + if (props.y < 0 || props.y > 1) { + throw new Error('LiveryDecal y coordinate must be between 0 and 1 (normalized)'); + } + + if (props.width <= 0 || props.width > 1) { + throw new Error('LiveryDecal width must be between 0 and 1 (normalized)'); + } + + if (props.height <= 0 || props.height > 1) { + throw new Error('LiveryDecal height must be between 0 and 1 (normalized)'); + } + + if (!Number.isInteger(props.zIndex) || props.zIndex < 0) { + throw new Error('LiveryDecal zIndex must be a non-negative integer'); + } + + if (!props.type) { + throw new Error('LiveryDecal type is required'); + } + } + + /** + * Move decal to new position + */ + moveTo(x: number, y: number): LiveryDecal { + return LiveryDecal.create({ + ...this, + x, + y, + }); + } + + /** + * Resize decal + */ + resize(width: number, height: number): LiveryDecal { + return LiveryDecal.create({ + ...this, + width, + height, + }); + } + + /** + * Change z-index + */ + setZIndex(zIndex: number): LiveryDecal { + return LiveryDecal.create({ + ...this, + zIndex, + }); + } + + /** + * Check if this decal overlaps with another + */ + overlapsWith(other: LiveryDecal): boolean { + const thisRight = this.x + this.width; + const thisBottom = this.y + this.height; + const otherRight = other.x + other.width; + const otherBottom = other.y + other.height; + + return !( + thisRight <= other.x || + this.x >= otherRight || + thisBottom <= other.y || + this.y >= otherBottom + ); + } +} \ No newline at end of file diff --git a/packages/racing/domain/value-objects/MembershipFee.ts b/packages/racing/domain/value-objects/MembershipFee.ts new file mode 100644 index 000000000..4bd7a7008 --- /dev/null +++ b/packages/racing/domain/value-objects/MembershipFee.ts @@ -0,0 +1,74 @@ +/** + * Value Object: MembershipFee + * Represents membership fee configuration for league drivers + */ + +import type { Money } from './Money'; + +export type MembershipFeeType = 'season' | 'monthly' | 'per_race'; + +export interface MembershipFeeProps { + type: MembershipFeeType; + amount: Money; +} + +export class MembershipFee { + readonly type: MembershipFeeType; + readonly amount: Money; + + private constructor(props: MembershipFeeProps) { + this.type = props.type; + this.amount = props.amount; + } + + static create(type: MembershipFeeType, amount: Money): MembershipFee { + if (!type) { + throw new Error('MembershipFee type is required'); + } + + if (!amount) { + throw new Error('MembershipFee amount is required'); + } + + if (amount.amount < 0) { + throw new Error('MembershipFee amount cannot be negative'); + } + + return new MembershipFee({ type, amount }); + } + + /** + * Get platform fee for this membership fee + */ + getPlatformFee(): Money { + return this.amount.calculatePlatformFee(); + } + + /** + * Get net amount after platform fee + */ + getNetAmount(): Money { + return this.amount.calculateNetAmount(); + } + + /** + * Check if this is a recurring fee + */ + isRecurring(): boolean { + return this.type === 'monthly'; + } + + /** + * Get display name for fee type + */ + getDisplayName(): string { + switch (this.type) { + case 'season': + return 'Season Fee'; + case 'monthly': + return 'Monthly Subscription'; + case 'per_race': + return 'Per-Race Fee'; + } + } +} \ No newline at end of file diff --git a/packages/racing/domain/value-objects/Money.ts b/packages/racing/domain/value-objects/Money.ts new file mode 100644 index 000000000..7c54ce647 --- /dev/null +++ b/packages/racing/domain/value-objects/Money.ts @@ -0,0 +1,98 @@ +/** + * Value Object: Money + * Represents a monetary amount with currency and platform fee calculation + */ + +export type Currency = 'USD' | 'EUR' | 'GBP'; + +export class Money { + private static readonly PLATFORM_FEE_PERCENTAGE = 0.10; + + readonly amount: number; + readonly currency: Currency; + + private constructor(amount: number, currency: Currency) { + this.amount = amount; + this.currency = currency; + } + + static create(amount: number, currency: Currency = 'USD'): Money { + if (amount < 0) { + throw new Error('Money amount cannot be negative'); + } + if (!Number.isFinite(amount)) { + throw new Error('Money amount must be a finite number'); + } + return new Money(amount, currency); + } + + /** + * Calculate platform fee (10%) + */ + calculatePlatformFee(): Money { + const feeAmount = this.amount * Money.PLATFORM_FEE_PERCENTAGE; + return new Money(feeAmount, this.currency); + } + + /** + * Calculate net amount after platform fee + */ + calculateNetAmount(): Money { + const platformFee = this.calculatePlatformFee(); + return new Money(this.amount - platformFee.amount, this.currency); + } + + /** + * Add two money amounts + */ + add(other: Money): Money { + if (this.currency !== other.currency) { + throw new Error('Cannot add money with different currencies'); + } + return new Money(this.amount + other.amount, this.currency); + } + + /** + * Subtract two money amounts + */ + subtract(other: Money): Money { + if (this.currency !== other.currency) { + throw new Error('Cannot subtract money with different currencies'); + } + const result = this.amount - other.amount; + if (result < 0) { + throw new Error('Subtraction would result in negative amount'); + } + return new Money(result, this.currency); + } + + /** + * Check if this money is greater than another + */ + isGreaterThan(other: Money): boolean { + if (this.currency !== other.currency) { + throw new Error('Cannot compare money with different currencies'); + } + return this.amount > other.amount; + } + + /** + * Check if this money equals another + */ + equals(other: Money): boolean { + return this.amount === other.amount && this.currency === other.currency; + } + + /** + * Format money for display + */ + format(): string { + const formatter = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: this.currency, + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); + return formatter.format(this.amount); + } +} \ No newline at end of file diff --git a/packages/racing/infrastructure/repositories/InMemoryLeagueWalletRepository.ts b/packages/racing/infrastructure/repositories/InMemoryLeagueWalletRepository.ts new file mode 100644 index 000000000..2d5b028e0 --- /dev/null +++ b/packages/racing/infrastructure/repositories/InMemoryLeagueWalletRepository.ts @@ -0,0 +1,54 @@ +/** + * In-Memory Implementation: ILeagueWalletRepository + * + * Mock repository for testing and development + */ + +import type { LeagueWallet } from '../../domain/entities/LeagueWallet'; +import type { ILeagueWalletRepository } from '../../domain/repositories/ILeagueWalletRepository'; + +export class InMemoryLeagueWalletRepository implements ILeagueWalletRepository { + private wallets: Map = new Map(); + + async findById(id: string): Promise { + return this.wallets.get(id) ?? null; + } + + async findByLeagueId(leagueId: string): Promise { + for (const wallet of this.wallets.values()) { + if (wallet.leagueId === leagueId) { + return wallet; + } + } + return null; + } + + async create(wallet: LeagueWallet): Promise { + if (this.wallets.has(wallet.id)) { + throw new Error('LeagueWallet with this ID already exists'); + } + this.wallets.set(wallet.id, wallet); + return wallet; + } + + async update(wallet: LeagueWallet): Promise { + if (!this.wallets.has(wallet.id)) { + throw new Error('LeagueWallet not found'); + } + this.wallets.set(wallet.id, wallet); + return wallet; + } + + async delete(id: string): Promise { + this.wallets.delete(id); + } + + async exists(id: string): Promise { + return this.wallets.has(id); + } + + // Test helper + clear(): void { + this.wallets.clear(); + } +} \ No newline at end of file diff --git a/packages/racing/infrastructure/repositories/InMemoryLiveryRepository.ts b/packages/racing/infrastructure/repositories/InMemoryLiveryRepository.ts new file mode 100644 index 000000000..c9e16c996 --- /dev/null +++ b/packages/racing/infrastructure/repositories/InMemoryLiveryRepository.ts @@ -0,0 +1,104 @@ +/** + * In-Memory Implementation: ILiveryRepository + * + * Mock repository for testing and development + */ + +import type { DriverLivery } from '../../domain/entities/DriverLivery'; +import type { LiveryTemplate } from '../../domain/entities/LiveryTemplate'; +import type { ILiveryRepository } from '../../domain/repositories/ILiveryRepository'; + +export class InMemoryLiveryRepository implements ILiveryRepository { + private driverLiveries: Map = new Map(); + private templates: Map = new Map(); + + // DriverLivery operations + async findDriverLiveryById(id: string): Promise { + return this.driverLiveries.get(id) ?? null; + } + + async findDriverLiveriesByDriverId(driverId: string): Promise { + return Array.from(this.driverLiveries.values()).filter(l => l.driverId === driverId); + } + + async findDriverLiveryByDriverAndCar(driverId: string, carId: string): Promise { + for (const livery of this.driverLiveries.values()) { + if (livery.driverId === driverId && livery.carId === carId) { + return livery; + } + } + return null; + } + + async createDriverLivery(livery: DriverLivery): Promise { + if (this.driverLiveries.has(livery.id)) { + throw new Error('DriverLivery with this ID already exists'); + } + this.driverLiveries.set(livery.id, livery); + return livery; + } + + async updateDriverLivery(livery: DriverLivery): Promise { + if (!this.driverLiveries.has(livery.id)) { + throw new Error('DriverLivery not found'); + } + this.driverLiveries.set(livery.id, livery); + return livery; + } + + async deleteDriverLivery(id: string): Promise { + this.driverLiveries.delete(id); + } + + // LiveryTemplate operations + async findTemplateById(id: string): Promise { + return this.templates.get(id) ?? null; + } + + async findTemplatesBySeasonId(seasonId: string): Promise { + return Array.from(this.templates.values()).filter(t => t.seasonId === seasonId); + } + + async findTemplateBySeasonAndCar(seasonId: string, carId: string): Promise { + for (const template of this.templates.values()) { + if (template.seasonId === seasonId && template.carId === carId) { + return template; + } + } + return null; + } + + async createTemplate(template: LiveryTemplate): Promise { + if (this.templates.has(template.id)) { + throw new Error('LiveryTemplate with this ID already exists'); + } + this.templates.set(template.id, template); + return template; + } + + async updateTemplate(template: LiveryTemplate): Promise { + if (!this.templates.has(template.id)) { + throw new Error('LiveryTemplate not found'); + } + this.templates.set(template.id, template); + return template; + } + + async deleteTemplate(id: string): Promise { + this.templates.delete(id); + } + + // Test helpers + clearDriverLiveries(): void { + this.driverLiveries.clear(); + } + + clearTemplates(): void { + this.templates.clear(); + } + + clear(): void { + this.driverLiveries.clear(); + this.templates.clear(); + } +} \ No newline at end of file diff --git a/packages/racing/infrastructure/repositories/InMemorySeasonSponsorshipRepository.ts b/packages/racing/infrastructure/repositories/InMemorySeasonSponsorshipRepository.ts new file mode 100644 index 000000000..b2d3681a6 --- /dev/null +++ b/packages/racing/infrastructure/repositories/InMemorySeasonSponsorshipRepository.ts @@ -0,0 +1,59 @@ +/** + * In-Memory Implementation: ISeasonSponsorshipRepository + * + * Mock repository for testing and development + */ + +import type { SeasonSponsorship, SponsorshipTier } from '../../domain/entities/SeasonSponsorship'; +import type { ISeasonSponsorshipRepository } from '../../domain/repositories/ISeasonSponsorshipRepository'; + +export class InMemorySeasonSponsorshipRepository implements ISeasonSponsorshipRepository { + private sponsorships: Map = new Map(); + + async findById(id: string): Promise { + return this.sponsorships.get(id) ?? null; + } + + async findBySeasonId(seasonId: string): Promise { + return Array.from(this.sponsorships.values()).filter(s => s.seasonId === seasonId); + } + + async findBySponsorId(sponsorId: string): Promise { + return Array.from(this.sponsorships.values()).filter(s => s.sponsorId === sponsorId); + } + + async findBySeasonAndTier(seasonId: string, tier: SponsorshipTier): Promise { + return Array.from(this.sponsorships.values()).filter( + s => s.seasonId === seasonId && s.tier === tier + ); + } + + async create(sponsorship: SeasonSponsorship): Promise { + if (this.sponsorships.has(sponsorship.id)) { + throw new Error('SeasonSponsorship with this ID already exists'); + } + this.sponsorships.set(sponsorship.id, sponsorship); + return sponsorship; + } + + async update(sponsorship: SeasonSponsorship): Promise { + if (!this.sponsorships.has(sponsorship.id)) { + throw new Error('SeasonSponsorship not found'); + } + this.sponsorships.set(sponsorship.id, sponsorship); + return sponsorship; + } + + async delete(id: string): Promise { + this.sponsorships.delete(id); + } + + async exists(id: string): Promise { + return this.sponsorships.has(id); + } + + // Test helper + clear(): void { + this.sponsorships.clear(); + } +} \ No newline at end of file diff --git a/packages/racing/infrastructure/repositories/InMemorySponsorRepository.ts b/packages/racing/infrastructure/repositories/InMemorySponsorRepository.ts new file mode 100644 index 000000000..317e265e3 --- /dev/null +++ b/packages/racing/infrastructure/repositories/InMemorySponsorRepository.ts @@ -0,0 +1,58 @@ +/** + * In-Memory Implementation: ISponsorRepository + * + * Mock repository for testing and development + */ + +import type { Sponsor } from '../../domain/entities/Sponsor'; +import type { ISponsorRepository } from '../../domain/repositories/ISponsorRepository'; + +export class InMemorySponsorRepository implements ISponsorRepository { + private sponsors: Map = new Map(); + + async findById(id: string): Promise { + return this.sponsors.get(id) ?? null; + } + + async findAll(): Promise { + return Array.from(this.sponsors.values()); + } + + async findByEmail(email: string): Promise { + for (const sponsor of this.sponsors.values()) { + if (sponsor.contactEmail === email) { + return sponsor; + } + } + return null; + } + + async create(sponsor: Sponsor): Promise { + if (this.sponsors.has(sponsor.id)) { + throw new Error('Sponsor with this ID already exists'); + } + this.sponsors.set(sponsor.id, sponsor); + return sponsor; + } + + async update(sponsor: Sponsor): Promise { + if (!this.sponsors.has(sponsor.id)) { + throw new Error('Sponsor not found'); + } + this.sponsors.set(sponsor.id, sponsor); + return sponsor; + } + + async delete(id: string): Promise { + this.sponsors.delete(id); + } + + async exists(id: string): Promise { + return this.sponsors.has(id); + } + + // Test helper + clear(): void { + this.sponsors.clear(); + } +} \ No newline at end of file diff --git a/packages/racing/infrastructure/repositories/InMemoryTransactionRepository.ts b/packages/racing/infrastructure/repositories/InMemoryTransactionRepository.ts new file mode 100644 index 000000000..2cdb2cd23 --- /dev/null +++ b/packages/racing/infrastructure/repositories/InMemoryTransactionRepository.ts @@ -0,0 +1,53 @@ +/** + * In-Memory Implementation: ITransactionRepository + * + * Mock repository for testing and development + */ + +import type { Transaction, TransactionType } from '../../domain/entities/Transaction'; +import type { ITransactionRepository } from '../../domain/repositories/ITransactionRepository'; + +export class InMemoryTransactionRepository implements ITransactionRepository { + private transactions: Map = new Map(); + + async findById(id: string): Promise { + return this.transactions.get(id) ?? null; + } + + async findByWalletId(walletId: string): Promise { + return Array.from(this.transactions.values()).filter(t => t.walletId === walletId); + } + + async findByType(type: TransactionType): Promise { + return Array.from(this.transactions.values()).filter(t => t.type === type); + } + + async create(transaction: Transaction): Promise { + if (this.transactions.has(transaction.id)) { + throw new Error('Transaction with this ID already exists'); + } + this.transactions.set(transaction.id, transaction); + return transaction; + } + + async update(transaction: Transaction): Promise { + if (!this.transactions.has(transaction.id)) { + throw new Error('Transaction not found'); + } + this.transactions.set(transaction.id, transaction); + return transaction; + } + + async delete(id: string): Promise { + this.transactions.delete(id); + } + + async exists(id: string): Promise { + return this.transactions.has(id); + } + + // Test helper + clear(): void { + this.transactions.clear(); + } +} \ No newline at end of file