Some checks failed
CI / lint-typecheck (pull_request) Failing after 4m50s
CI / tests (pull_request) Has been skipped
CI / contract-tests (pull_request) Has been skipped
CI / e2e-tests (pull_request) Has been skipped
CI / comment-pr (pull_request) Has been skipped
CI / commit-types (pull_request) Has been skipped
176 lines
6.0 KiB
TypeScript
176 lines
6.0 KiB
TypeScript
import type { SeasonSponsorshipRepository } from '@core/racing/domain/repositories/SeasonSponsorshipRepository';
|
|
import type { UseCase } from '@core/shared/application/UseCase';
|
|
import { Result } from '@core/shared/domain/Result';
|
|
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
|
import { PaymentStatus, PaymentType } from '../../domain/entities/Payment';
|
|
import type { PaymentRepository } from '../../domain/repositories/PaymentRepository';
|
|
import type { SponsorRepository } from '@core/racing/domain/repositories/SponsorRepository';
|
|
|
|
export interface SponsorBillingStats {
|
|
totalSpent: number;
|
|
pendingAmount: number;
|
|
nextPaymentDate: string | null;
|
|
nextPaymentAmount: number | null;
|
|
activeSponsorships: number;
|
|
averageMonthlySpend: number;
|
|
}
|
|
|
|
export interface SponsorInvoiceSummary {
|
|
id: string;
|
|
invoiceNumber: string;
|
|
date: string;
|
|
dueDate: string;
|
|
amount: number;
|
|
vatAmount: number;
|
|
totalAmount: number;
|
|
status: 'paid' | 'pending' | 'overdue' | 'failed';
|
|
description: string;
|
|
sponsorshipType: 'league' | 'team' | 'driver' | 'race' | 'platform';
|
|
pdfUrl: string;
|
|
}
|
|
|
|
export interface SponsorPaymentMethodSummary {
|
|
id: string;
|
|
type: 'card' | 'bank' | 'sepa';
|
|
last4: string;
|
|
brand?: string;
|
|
isDefault: boolean;
|
|
expiryMonth?: number;
|
|
expiryYear?: number;
|
|
bankName?: string;
|
|
}
|
|
|
|
export interface SponsorBillingSummary {
|
|
paymentMethods: SponsorPaymentMethodSummary[];
|
|
invoices: SponsorInvoiceSummary[];
|
|
stats: SponsorBillingStats;
|
|
}
|
|
|
|
export interface GetSponsorBillingInput {
|
|
sponsorId: string;
|
|
}
|
|
|
|
export interface GetSponsorBillingResult {
|
|
paymentMethods: SponsorPaymentMethodSummary[];
|
|
invoices: SponsorInvoiceSummary[];
|
|
stats: SponsorBillingStats;
|
|
}
|
|
|
|
export type GetSponsorBillingErrorCode = 'SPONSOR_NOT_FOUND';
|
|
|
|
export class GetSponsorBillingUseCase
|
|
implements UseCase<GetSponsorBillingInput, GetSponsorBillingResult, GetSponsorBillingErrorCode>
|
|
{
|
|
constructor(
|
|
private readonly paymentRepository: PaymentRepository,
|
|
private readonly seasonSponsorshipRepository: SeasonSponsorshipRepository,
|
|
private readonly sponsorRepository: SponsorRepository,
|
|
) {}
|
|
|
|
async execute(input: GetSponsorBillingInput): Promise<Result<GetSponsorBillingResult, ApplicationErrorCode<GetSponsorBillingErrorCode>>> {
|
|
const { sponsorId } = input;
|
|
|
|
const sponsor = await this.sponsorRepository.findById(sponsorId);
|
|
if (!sponsor) {
|
|
return Result.err({
|
|
code: 'SPONSOR_NOT_FOUND',
|
|
details: { message: 'Sponsor not found' },
|
|
});
|
|
}
|
|
|
|
// In this in-memory implementation we derive billing data from payments
|
|
// where the sponsor is the payer.
|
|
const payments = await this.paymentRepository.findByFilters({
|
|
payerId: sponsorId,
|
|
type: PaymentType.SPONSORSHIP,
|
|
});
|
|
|
|
const sponsorships = await this.seasonSponsorshipRepository.findBySponsorId(sponsorId);
|
|
|
|
const invoices: SponsorInvoiceSummary[] = payments.map((payment, index) => {
|
|
const createdAt = payment.createdAt ?? new Date();
|
|
const dueDate = new Date(createdAt.getTime());
|
|
dueDate.setDate(dueDate.getDate() + 14);
|
|
|
|
const vatAmount = +(payment.amount * 0.19).toFixed(2);
|
|
const totalAmount = +(payment.amount + vatAmount).toFixed(2);
|
|
|
|
let status: 'paid' | 'pending' | 'overdue' | 'failed' = 'pending';
|
|
if (payment.status === PaymentStatus.COMPLETED) status = 'paid';
|
|
else if (payment.status === PaymentStatus.FAILED || payment.status === PaymentStatus.REFUNDED) status = 'failed';
|
|
|
|
const now = new Date();
|
|
if (status === 'pending' && dueDate < now) {
|
|
status = 'overdue';
|
|
}
|
|
|
|
return {
|
|
id: payment.id,
|
|
invoiceNumber: `GP-${createdAt.getFullYear()}-${String(index + 1).padStart(6, '0')}`,
|
|
date: createdAt.toISOString(),
|
|
dueDate: dueDate.toISOString(),
|
|
amount: payment.amount,
|
|
vatAmount,
|
|
totalAmount,
|
|
status,
|
|
description: 'Sponsorship payment',
|
|
sponsorshipType: 'league',
|
|
pdfUrl: '#',
|
|
};
|
|
});
|
|
|
|
const totalSpent = invoices
|
|
.filter(i => i.status === 'paid')
|
|
.reduce((sum, i) => sum + i.totalAmount, 0);
|
|
|
|
const pendingAmount = invoices
|
|
.filter(i => i.status === 'pending' || i.status === 'overdue')
|
|
.reduce((sum, i) => sum + i.totalAmount, 0);
|
|
|
|
const pendingInvoices = invoices.filter(i => i.status === 'pending' || i.status === 'overdue');
|
|
const nextPending = pendingInvoices.length > 0
|
|
? pendingInvoices.reduce((earliest, current) => (current.dueDate < earliest.dueDate ? current : earliest))
|
|
: null;
|
|
|
|
const stats: SponsorBillingStats = {
|
|
totalSpent,
|
|
pendingAmount,
|
|
nextPaymentDate: nextPending ? nextPending.dueDate : null,
|
|
nextPaymentAmount: nextPending ? nextPending.totalAmount : null,
|
|
activeSponsorships: sponsorships.filter(s => s.status === 'active').length,
|
|
averageMonthlySpend: this.calculateAverageMonthlySpend(invoices),
|
|
};
|
|
|
|
// NOTE: Payment methods are not yet persisted in core. For now, we expose
|
|
// an empty list so the UI can still render correctly. A dedicated
|
|
// payment-methods port can be added later when the concept exists in core.
|
|
const paymentMethods: SponsorPaymentMethodSummary[] = [];
|
|
|
|
const result: GetSponsorBillingResult = {
|
|
paymentMethods,
|
|
invoices,
|
|
stats,
|
|
};
|
|
|
|
return Result.ok(result);
|
|
}
|
|
|
|
private calculateAverageMonthlySpend(invoices: SponsorInvoiceSummary[]): number {
|
|
if (invoices.length === 0) return 0;
|
|
|
|
const sorted = [...invoices].sort((a, b) => a.date.localeCompare(b.date));
|
|
const first = new Date(sorted[0]!.date);
|
|
const last = new Date(sorted[sorted.length - 1]!.date);
|
|
|
|
const months = this.monthDiff(first, last) || 1;
|
|
const total = sorted.reduce((sum, inv) => sum + inv.totalAmount, 0);
|
|
|
|
return Math.round((total / months) * 100) / 100;
|
|
}
|
|
|
|
private monthDiff(d1: Date, d2: Date): number {
|
|
const years = d2.getFullYear() - d1.getFullYear();
|
|
const months = d2.getMonth() - d1.getMonth();
|
|
return years * 12 + months + 1;
|
|
}
|
|
} |