diff --git a/CLAUDE.md b/CLAUDE.md index 2ae51dc..ac2e600 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,6 +84,8 @@ Docker-compose defaults (no `.env` changes needed for local dev): - **Redis**: password `xpeditis_redis_password`, port 6379 - **MinIO** (S3-compatible storage): `minioadmin:minioadmin`, API port 9000, console port 9001 +Frontend env var: `NEXT_PUBLIC_API_URL` (defaults to `http://localhost:4000`) — configured in `next.config.js`. + ## Architecture ### Hexagonal Architecture (Backend) @@ -186,7 +188,14 @@ Immutable, self-validating via static `create()`. E.g. `Money` supports USD, EUR - Separate mapper classes (`infrastructure/persistence/typeorm/mappers/`) with static `toOrm()`, `toDomain()`, `toDomainMany()` methods ### Frontend API Client -Custom Fetch wrapper in `src/lib/api/client.ts` — exports `get()`, `post()`, `patch()`, `del()`, `upload()`, `download()`. Auto-refreshes JWT on 401. Tokens stored in localStorage. Per-module files (auth.ts, bookings.ts, rates.ts, etc.) import from client. +Custom Fetch wrapper in `src/lib/api/client.ts` — exports `get()`, `post()`, `patch()`, `del()`, `upload()`, `download()`. Auto-refreshes JWT on 401. Tokens stored in localStorage **and synced to cookies** (`accessToken` cookie) so Next.js middleware can read them server-side. Per-module files (auth.ts, bookings.ts, rates.ts, etc.) import from client. + +### Route Protection (Middleware) +`apps/frontend/middleware.ts` checks the `accessToken` cookie to protect routes. Public paths are defined in two lists: +- `exactPublicPaths`: exact matches (e.g. `/`) +- `prefixPublicPaths`: prefix matches including sub-paths (e.g. `/login`, `/carrier`, `/about`, etc.) + +All other routes redirect to `/login?redirect=` when the cookie is absent. ### Application Decorators - `@Public()` — skip JWT auth diff --git a/apps/backend/.env.example b/apps/backend/.env.example index 831f9b8..a91ee8a 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -93,9 +93,9 @@ STRIPE_SECRET_KEY=sk_test_your_stripe_secret_key STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret # Stripe Price IDs (create these in Stripe Dashboard) -STRIPE_STARTER_MONTHLY_PRICE_ID=price_starter_monthly -STRIPE_STARTER_YEARLY_PRICE_ID=price_starter_yearly -STRIPE_PRO_MONTHLY_PRICE_ID=price_pro_monthly -STRIPE_PRO_YEARLY_PRICE_ID=price_pro_yearly -STRIPE_ENTERPRISE_MONTHLY_PRICE_ID=price_enterprise_monthly -STRIPE_ENTERPRISE_YEARLY_PRICE_ID=price_enterprise_yearly +STRIPE_SILVER_MONTHLY_PRICE_ID=price_silver_monthly +STRIPE_SILVER_YEARLY_PRICE_ID=price_silver_yearly +STRIPE_GOLD_MONTHLY_PRICE_ID=price_gold_monthly +STRIPE_GOLD_YEARLY_PRICE_ID=price_gold_yearly +STRIPE_PLATINIUM_MONTHLY_PRICE_ID=price_platinium_monthly +STRIPE_PLATINIUM_YEARLY_PRICE_ID=price_platinium_yearly diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index cc54c5c..6675df5 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -60,12 +60,12 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard'; // Stripe Configuration (optional for development) STRIPE_SECRET_KEY: Joi.string().optional(), STRIPE_WEBHOOK_SECRET: Joi.string().optional(), - STRIPE_STARTER_MONTHLY_PRICE_ID: Joi.string().optional(), - STRIPE_STARTER_YEARLY_PRICE_ID: Joi.string().optional(), - STRIPE_PRO_MONTHLY_PRICE_ID: Joi.string().optional(), - STRIPE_PRO_YEARLY_PRICE_ID: Joi.string().optional(), - STRIPE_ENTERPRISE_MONTHLY_PRICE_ID: Joi.string().optional(), - STRIPE_ENTERPRISE_YEARLY_PRICE_ID: Joi.string().optional(), + STRIPE_SILVER_MONTHLY_PRICE_ID: Joi.string().optional(), + STRIPE_SILVER_YEARLY_PRICE_ID: Joi.string().optional(), + STRIPE_GOLD_MONTHLY_PRICE_ID: Joi.string().optional(), + STRIPE_GOLD_YEARLY_PRICE_ID: Joi.string().optional(), + STRIPE_PLATINIUM_MONTHLY_PRICE_ID: Joi.string().optional(), + STRIPE_PLATINIUM_YEARLY_PRICE_ID: Joi.string().optional(), }), }), diff --git a/apps/backend/src/application/auth/auth.service.ts b/apps/backend/src/application/auth/auth.service.ts index cbcc17d..2330fcf 100644 --- a/apps/backend/src/application/auth/auth.service.ts +++ b/apps/backend/src/application/auth/auth.service.ts @@ -25,6 +25,8 @@ export interface JwtPayload { email: string; role: string; organizationId: string; + plan?: string; // subscription plan (BRONZE, SILVER, GOLD, PLATINIUM) + planFeatures?: string[]; // plan feature flags type: 'access' | 'refresh'; } @@ -39,7 +41,7 @@ export class AuthService { private readonly organizationRepository: OrganizationRepository, private readonly jwtService: JwtService, private readonly configService: ConfigService, - private readonly subscriptionService: SubscriptionService, + private readonly subscriptionService: SubscriptionService ) {} /** @@ -220,11 +222,26 @@ export class AuthService { * Generate access and refresh tokens */ private async generateTokens(user: User): Promise<{ accessToken: string; refreshToken: string }> { + // Fetch subscription plan for JWT payload + let plan = 'BRONZE'; + let planFeatures: string[] = []; + try { + const subscription = await this.subscriptionService.getOrCreateSubscription( + user.organizationId + ); + plan = subscription.plan.value; + planFeatures = [...subscription.plan.planFeatures]; + } catch (error) { + this.logger.warn(`Failed to fetch subscription for JWT: ${error}`); + } + const accessPayload: JwtPayload = { sub: user.id, email: user.email, role: user.role, organizationId: user.organizationId, + plan, + planFeatures, type: 'access', }; @@ -233,6 +250,8 @@ export class AuthService { email: user.email, role: user.role, organizationId: user.organizationId, + plan, + planFeatures, type: 'refresh', }; diff --git a/apps/backend/src/application/bookings/bookings.module.ts b/apps/backend/src/application/bookings/bookings.module.ts index bdc06e3..2fbd920 100644 --- a/apps/backend/src/application/bookings/bookings.module.ts +++ b/apps/backend/src/application/bookings/bookings.module.ts @@ -6,15 +6,18 @@ import { BookingsController } from '../controllers/bookings.controller'; import { BOOKING_REPOSITORY } from '@domain/ports/out/booking.repository'; import { RATE_QUOTE_REPOSITORY } from '@domain/ports/out/rate-quote.repository'; import { USER_REPOSITORY } from '@domain/ports/out/user.repository'; +import { SHIPMENT_COUNTER_PORT } from '@domain/ports/out/shipment-counter.port'; import { TypeOrmBookingRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-booking.repository'; import { TypeOrmRateQuoteRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-rate-quote.repository'; import { TypeOrmUserRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-user.repository'; +import { TypeOrmShipmentCounterRepository } from '../../infrastructure/persistence/typeorm/repositories/shipment-counter.repository'; // Import ORM entities import { BookingOrmEntity } from '../../infrastructure/persistence/typeorm/entities/booking.orm-entity'; import { ContainerOrmEntity } from '../../infrastructure/persistence/typeorm/entities/container.orm-entity'; import { RateQuoteOrmEntity } from '../../infrastructure/persistence/typeorm/entities/rate-quote.orm-entity'; import { UserOrmEntity } from '../../infrastructure/persistence/typeorm/entities/user.orm-entity'; +import { CsvBookingOrmEntity } from '../../infrastructure/persistence/typeorm/entities/csv-booking.orm-entity'; // Import services and domain import { BookingService } from '@domain/services/booking.service'; @@ -29,6 +32,7 @@ import { StorageModule } from '../../infrastructure/storage/storage.module'; import { AuditModule } from '../audit/audit.module'; import { NotificationsModule } from '../notifications/notifications.module'; import { WebhooksModule } from '../webhooks/webhooks.module'; +import { SubscriptionsModule } from '../subscriptions/subscriptions.module'; /** * Bookings Module @@ -47,6 +51,7 @@ import { WebhooksModule } from '../webhooks/webhooks.module'; ContainerOrmEntity, RateQuoteOrmEntity, UserOrmEntity, + CsvBookingOrmEntity, ]), EmailModule, PdfModule, @@ -54,6 +59,7 @@ import { WebhooksModule } from '../webhooks/webhooks.module'; AuditModule, NotificationsModule, WebhooksModule, + SubscriptionsModule, ], controllers: [BookingsController], providers: [ @@ -73,6 +79,10 @@ import { WebhooksModule } from '../webhooks/webhooks.module'; provide: USER_REPOSITORY, useClass: TypeOrmUserRepository, }, + { + provide: SHIPMENT_COUNTER_PORT, + useClass: TypeOrmShipmentCounterRepository, + }, ], exports: [BOOKING_REPOSITORY], }) diff --git a/apps/backend/src/application/controllers/bookings.controller.ts b/apps/backend/src/application/controllers/bookings.controller.ts index 386c25d..921aa73 100644 --- a/apps/backend/src/application/controllers/bookings.controller.ts +++ b/apps/backend/src/application/controllers/bookings.controller.ts @@ -53,6 +53,12 @@ import { NotificationService } from '../services/notification.service'; import { NotificationsGateway } from '../gateways/notifications.gateway'; import { WebhookService } from '../services/webhook.service'; import { WebhookEvent } from '@domain/entities/webhook.entity'; +import { + ShipmentCounterPort, + SHIPMENT_COUNTER_PORT, +} from '@domain/ports/out/shipment-counter.port'; +import { SubscriptionService } from '../services/subscription.service'; +import { ShipmentLimitExceededException } from '@domain/exceptions/shipment-limit-exceeded.exception'; @ApiTags('Bookings') @Controller('bookings') @@ -70,7 +76,9 @@ export class BookingsController { private readonly auditService: AuditService, private readonly notificationService: NotificationService, private readonly notificationsGateway: NotificationsGateway, - private readonly webhookService: WebhookService + private readonly webhookService: WebhookService, + @Inject(SHIPMENT_COUNTER_PORT) private readonly shipmentCounter: ShipmentCounterPort, + private readonly subscriptionService: SubscriptionService ) {} @Post() @@ -105,6 +113,22 @@ export class BookingsController { ): Promise { this.logger.log(`[User: ${user.email}] Creating booking for rate quote: ${dto.rateQuoteId}`); + // Check shipment limit for Bronze plan + const subscription = await this.subscriptionService.getOrCreateSubscription( + user.organizationId + ); + const maxShipments = subscription.plan.maxShipmentsPerYear; + if (maxShipments !== -1) { + const currentYear = new Date().getFullYear(); + const count = await this.shipmentCounter.countShipmentsForOrganizationInYear( + user.organizationId, + currentYear + ); + if (count >= maxShipments) { + throw new ShipmentLimitExceededException(user.organizationId, count, maxShipments); + } + } + try { // Convert DTO to domain input, using authenticated user's data const input = { @@ -456,9 +480,16 @@ export class BookingsController { // Filter out bookings or rate quotes that are null const bookingsWithQuotes = bookingsWithQuotesRaw.filter( - (item): item is { booking: NonNullable; rateQuote: NonNullable } => - item.booking !== null && item.booking !== undefined && - item.rateQuote !== null && item.rateQuote !== undefined + ( + item + ): item is { + booking: NonNullable; + rateQuote: NonNullable; + } => + item.booking !== null && + item.booking !== undefined && + item.rateQuote !== null && + item.rateQuote !== undefined ); // Convert to DTOs diff --git a/apps/backend/src/application/controllers/csv-bookings.controller.ts b/apps/backend/src/application/controllers/csv-bookings.controller.ts index 6ee0ad0..01e9aed 100644 --- a/apps/backend/src/application/controllers/csv-bookings.controller.ts +++ b/apps/backend/src/application/controllers/csv-bookings.controller.ts @@ -14,7 +14,9 @@ import { BadRequestException, ParseIntPipe, DefaultValuePipe, + Inject, } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { FilesInterceptor } from '@nestjs/platform-express'; import { ApiTags, @@ -29,6 +31,12 @@ import { import { JwtAuthGuard } from '../guards/jwt-auth.guard'; import { Public } from '../decorators/public.decorator'; import { CsvBookingService } from '../services/csv-booking.service'; +import { SubscriptionService } from '../services/subscription.service'; +import { + ShipmentCounterPort, + SHIPMENT_COUNTER_PORT, +} from '@domain/ports/out/shipment-counter.port'; +import { ShipmentLimitExceededException } from '@domain/exceptions/shipment-limit-exceeded.exception'; import { CreateCsvBookingDto, CsvBookingResponseDto, @@ -48,7 +56,13 @@ import { @ApiTags('CSV Bookings') @Controller('csv-bookings') export class CsvBookingsController { - constructor(private readonly csvBookingService: CsvBookingService) {} + constructor( + private readonly csvBookingService: CsvBookingService, + private readonly subscriptionService: SubscriptionService, + private readonly configService: ConfigService, + @Inject(SHIPMENT_COUNTER_PORT) + private readonly shipmentCounter: ShipmentCounterPort + ) {} // ============================================================================ // STATIC ROUTES (must come FIRST) @@ -60,7 +74,6 @@ export class CsvBookingsController { * POST /api/v1/csv-bookings */ @Post() - @UseGuards(JwtAuthGuard) @ApiBearerAuth() @UseInterceptors(FilesInterceptor('documents', 10)) @ApiConsumes('multipart/form-data') @@ -144,6 +157,20 @@ export class CsvBookingsController { const userId = req.user.id; const organizationId = req.user.organizationId; + // Check shipment limit (Bronze plan = 12/year) + const subscription = await this.subscriptionService.getOrCreateSubscription(organizationId); + const maxShipments = subscription.plan.maxShipmentsPerYear; + if (maxShipments !== -1) { + const currentYear = new Date().getFullYear(); + const count = await this.shipmentCounter.countShipmentsForOrganizationInYear( + organizationId, + currentYear + ); + if (count >= maxShipments) { + throw new ShipmentLimitExceededException(organizationId, count, maxShipments); + } + } + // Convert string values to numbers (multipart/form-data sends everything as strings) const sanitizedDto: CreateCsvBookingDto = { ...dto, @@ -341,6 +368,85 @@ export class CsvBookingsController { }; } + /** + * Create Stripe Checkout session for commission payment + * + * POST /api/v1/csv-bookings/:id/pay + */ + @Post(':id/pay') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Pay commission for a booking', + description: + 'Creates a Stripe Checkout session for the commission payment. Returns the Stripe session URL to redirect the user to.', + }) + @ApiParam({ name: 'id', description: 'Booking ID (UUID)' }) + @ApiResponse({ + status: 200, + description: 'Stripe checkout session created', + schema: { + type: 'object', + properties: { + sessionUrl: { type: 'string' }, + sessionId: { type: 'string' }, + commissionAmountEur: { type: 'number' }, + }, + }, + }) + @ApiResponse({ status: 400, description: 'Booking not in PENDING_PAYMENT status' }) + @ApiResponse({ status: 404, description: 'Booking not found' }) + async payCommission(@Param('id') id: string, @Request() req: any) { + const userId = req.user.id; + const userEmail = req.user.email; + const frontendUrl = this.configService.get('FRONTEND_URL') || 'http://localhost:3000'; + + return await this.csvBookingService.createCommissionPayment(id, userId, userEmail, frontendUrl); + } + + /** + * Confirm commission payment after Stripe redirect + * + * POST /api/v1/csv-bookings/:id/confirm-payment + */ + @Post(':id/confirm-payment') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Confirm commission payment', + description: + 'Called after Stripe payment success. Verifies the payment, updates booking to PENDING, sends email to carrier.', + }) + @ApiParam({ name: 'id', description: 'Booking ID (UUID)' }) + @ApiBody({ + schema: { + type: 'object', + required: ['sessionId'], + properties: { + sessionId: { type: 'string', description: 'Stripe Checkout session ID' }, + }, + }, + }) + @ApiResponse({ + status: 200, + description: 'Payment confirmed, booking activated', + type: CsvBookingResponseDto, + }) + @ApiResponse({ status: 400, description: 'Payment not completed or session mismatch' }) + @ApiResponse({ status: 404, description: 'Booking not found' }) + async confirmPayment( + @Param('id') id: string, + @Body('sessionId') sessionId: string, + @Request() req: any + ): Promise { + if (!sessionId) { + throw new BadRequestException('sessionId is required'); + } + + const userId = req.user.id; + return await this.csvBookingService.confirmCommissionPayment(id, sessionId, userId); + } + // ============================================================================ // PARAMETERIZED ROUTES (must come LAST) // ============================================================================ diff --git a/apps/backend/src/application/controllers/gdpr.controller.ts b/apps/backend/src/application/controllers/gdpr.controller.ts index 1c77436..ee37702 100644 --- a/apps/backend/src/application/controllers/gdpr.controller.ts +++ b/apps/backend/src/application/controllers/gdpr.controller.ts @@ -22,12 +22,7 @@ import { JwtAuthGuard } from '../guards/jwt-auth.guard'; import { CurrentUser } from '../decorators/current-user.decorator'; import { UserPayload } from '../decorators/current-user.decorator'; import { GDPRService } from '../services/gdpr.service'; -import { - UpdateConsentDto, - ConsentResponseDto, - WithdrawConsentDto, - ConsentSuccessDto, -} from '../dto/consent.dto'; +import { UpdateConsentDto, ConsentResponseDto, WithdrawConsentDto } from '../dto/consent.dto'; @ApiTags('GDPR') @Controller('gdpr') diff --git a/apps/backend/src/application/controllers/subscriptions.controller.ts b/apps/backend/src/application/controllers/subscriptions.controller.ts index f3e933b..0c7fb5c 100644 --- a/apps/backend/src/application/controllers/subscriptions.controller.ts +++ b/apps/backend/src/application/controllers/subscriptions.controller.ts @@ -77,7 +77,7 @@ export class SubscriptionsController { description: 'Forbidden - requires admin or manager role', }) async getSubscriptionOverview( - @CurrentUser() user: UserPayload, + @CurrentUser() user: UserPayload ): Promise { this.logger.log(`[User: ${user.email}] Getting subscription overview`); return this.subscriptionService.getSubscriptionOverview(user.organizationId); @@ -139,8 +139,7 @@ export class SubscriptionsController { @ApiBearerAuth() @ApiOperation({ summary: 'Create checkout session', - description: - 'Create a Stripe Checkout session for upgrading subscription. Admin/Manager only.', + description: 'Create a Stripe Checkout session for upgrading subscription. Admin/Manager only.', }) @ApiResponse({ status: 200, @@ -157,14 +156,10 @@ export class SubscriptionsController { }) async createCheckoutSession( @Body() dto: CreateCheckoutSessionDto, - @CurrentUser() user: UserPayload, + @CurrentUser() user: UserPayload ): Promise { this.logger.log(`[User: ${user.email}] Creating checkout session for plan: ${dto.plan}`); - return this.subscriptionService.createCheckoutSession( - user.organizationId, - user.id, - dto, - ); + return this.subscriptionService.createCheckoutSession(user.organizationId, user.id, dto); } /** @@ -195,7 +190,7 @@ export class SubscriptionsController { }) async createPortalSession( @Body() dto: CreatePortalSessionDto, - @CurrentUser() user: UserPayload, + @CurrentUser() user: UserPayload ): Promise { this.logger.log(`[User: ${user.email}] Creating portal session`); return this.subscriptionService.createPortalSession(user.organizationId, dto); @@ -230,10 +225,10 @@ export class SubscriptionsController { }) async syncFromStripe( @Body() dto: SyncSubscriptionDto, - @CurrentUser() user: UserPayload, + @CurrentUser() user: UserPayload ): Promise { this.logger.log( - `[User: ${user.email}] Syncing subscription from Stripe${dto.sessionId ? ` (sessionId: ${dto.sessionId})` : ''}`, + `[User: ${user.email}] Syncing subscription from Stripe${dto.sessionId ? ` (sessionId: ${dto.sessionId})` : ''}` ); return this.subscriptionService.syncFromStripe(user.organizationId, dto.sessionId); } @@ -247,7 +242,7 @@ export class SubscriptionsController { @ApiExcludeEndpoint() async handleWebhook( @Headers('stripe-signature') signature: string, - @Req() req: RawBodyRequest, + @Req() req: RawBodyRequest ): Promise<{ received: boolean }> { const rawBody = req.rawBody; if (!rawBody) { diff --git a/apps/backend/src/application/controllers/users.controller.ts b/apps/backend/src/application/controllers/users.controller.ts index 99793db..8483b6a 100644 --- a/apps/backend/src/application/controllers/users.controller.ts +++ b/apps/backend/src/application/controllers/users.controller.ts @@ -44,8 +44,10 @@ import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.reposito import { User, UserRole as DomainUserRole } from '@domain/entities/user.entity'; import { JwtAuthGuard } from '../guards/jwt-auth.guard'; import { RolesGuard } from '../guards/roles.guard'; +import { FeatureFlagGuard } from '../guards/feature-flag.guard'; import { CurrentUser, UserPayload } from '../decorators/current-user.decorator'; import { Roles } from '../decorators/roles.decorator'; +import { RequiresFeature } from '../decorators/requires-feature.decorator'; import { v4 as uuidv4 } from 'uuid'; import * as argon2 from 'argon2'; import * as crypto from 'crypto'; @@ -64,14 +66,15 @@ import { SubscriptionService } from '../services/subscription.service'; */ @ApiTags('Users') @Controller('users') -@UseGuards(JwtAuthGuard, RolesGuard) +@UseGuards(JwtAuthGuard, RolesGuard, FeatureFlagGuard) +@RequiresFeature('user_management') @ApiBearerAuth() export class UsersController { private readonly logger = new Logger(UsersController.name); constructor( @Inject(USER_REPOSITORY) private readonly userRepository: UserRepository, - private readonly subscriptionService: SubscriptionService, + private readonly subscriptionService: SubscriptionService ) {} /** @@ -284,7 +287,7 @@ export class UsersController { } catch (error) { this.logger.error(`Failed to reallocate license for user ${id}:`, error); throw new ForbiddenException( - 'Cannot reactivate user: no licenses available. Please upgrade your subscription.', + 'Cannot reactivate user: no licenses available. Please upgrade your subscription.' ); } } else { diff --git a/apps/backend/src/application/csv-bookings.module.ts b/apps/backend/src/application/csv-bookings.module.ts index b9b1ef2..c83fb0e 100644 --- a/apps/backend/src/application/csv-bookings.module.ts +++ b/apps/backend/src/application/csv-bookings.module.ts @@ -1,13 +1,18 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { ConfigModule } from '@nestjs/config'; import { CsvBookingsController } from './controllers/csv-bookings.controller'; import { CsvBookingActionsController } from './controllers/csv-booking-actions.controller'; import { CsvBookingService } from './services/csv-booking.service'; import { CsvBookingOrmEntity } from '../infrastructure/persistence/typeorm/entities/csv-booking.orm-entity'; import { TypeOrmCsvBookingRepository } from '../infrastructure/persistence/typeorm/repositories/csv-booking.repository'; +import { TypeOrmShipmentCounterRepository } from '../infrastructure/persistence/typeorm/repositories/shipment-counter.repository'; +import { SHIPMENT_COUNTER_PORT } from '@domain/ports/out/shipment-counter.port'; import { NotificationsModule } from './notifications/notifications.module'; import { EmailModule } from '../infrastructure/email/email.module'; import { StorageModule } from '../infrastructure/storage/storage.module'; +import { SubscriptionsModule } from './subscriptions/subscriptions.module'; +import { StripeModule } from '../infrastructure/stripe/stripe.module'; /** * CSV Bookings Module @@ -17,12 +22,22 @@ import { StorageModule } from '../infrastructure/storage/storage.module'; @Module({ imports: [ TypeOrmModule.forFeature([CsvBookingOrmEntity]), + ConfigModule, NotificationsModule, EmailModule, StorageModule, + SubscriptionsModule, + StripeModule, ], controllers: [CsvBookingsController, CsvBookingActionsController], - providers: [CsvBookingService, TypeOrmCsvBookingRepository], + providers: [ + CsvBookingService, + TypeOrmCsvBookingRepository, + { + provide: SHIPMENT_COUNTER_PORT, + useClass: TypeOrmShipmentCounterRepository, + }, + ], exports: [CsvBookingService, TypeOrmCsvBookingRepository], }) export class CsvBookingsModule {} diff --git a/apps/backend/src/application/dashboard/dashboard.controller.ts b/apps/backend/src/application/dashboard/dashboard.controller.ts index 12d6d2f..77c1dc8 100644 --- a/apps/backend/src/application/dashboard/dashboard.controller.ts +++ b/apps/backend/src/application/dashboard/dashboard.controller.ts @@ -7,9 +7,12 @@ import { Controller, Get, UseGuards, Request } from '@nestjs/common'; import { AnalyticsService } from '../services/analytics.service'; import { JwtAuthGuard } from '../guards/jwt-auth.guard'; +import { FeatureFlagGuard } from '../guards/feature-flag.guard'; +import { RequiresFeature } from '../decorators/requires-feature.decorator'; @Controller('dashboard') -@UseGuards(JwtAuthGuard) +@UseGuards(JwtAuthGuard, FeatureFlagGuard) +@RequiresFeature('dashboard') export class DashboardController { constructor(private readonly analyticsService: AnalyticsService) {} diff --git a/apps/backend/src/application/dashboard/dashboard.module.ts b/apps/backend/src/application/dashboard/dashboard.module.ts index 75cfaf8..b483b11 100644 --- a/apps/backend/src/application/dashboard/dashboard.module.ts +++ b/apps/backend/src/application/dashboard/dashboard.module.ts @@ -8,11 +8,13 @@ import { AnalyticsService } from '../services/analytics.service'; import { BookingsModule } from '../bookings/bookings.module'; import { RatesModule } from '../rates/rates.module'; import { CsvBookingsModule } from '../csv-bookings.module'; +import { SubscriptionsModule } from '../subscriptions/subscriptions.module'; +import { FeatureFlagGuard } from '../guards/feature-flag.guard'; @Module({ - imports: [BookingsModule, RatesModule, CsvBookingsModule], + imports: [BookingsModule, RatesModule, CsvBookingsModule, SubscriptionsModule], controllers: [DashboardController], - providers: [AnalyticsService], + providers: [AnalyticsService, FeatureFlagGuard], exports: [AnalyticsService], }) export class DashboardModule {} diff --git a/apps/backend/src/application/decorators/requires-feature.decorator.ts b/apps/backend/src/application/decorators/requires-feature.decorator.ts new file mode 100644 index 0000000..cdbe677 --- /dev/null +++ b/apps/backend/src/application/decorators/requires-feature.decorator.ts @@ -0,0 +1,15 @@ +import { SetMetadata } from '@nestjs/common'; +import { PlanFeature } from '@domain/value-objects/plan-feature.vo'; + +export const REQUIRED_FEATURES_KEY = 'requiredFeatures'; + +/** + * Decorator to require specific plan features for a route. + * Works with FeatureFlagGuard to enforce access control. + * + * Usage: + * @RequiresFeature('dashboard') + * @RequiresFeature('csv_export', 'api_access') + */ +export const RequiresFeature = (...features: PlanFeature[]) => + SetMetadata(REQUIRED_FEATURES_KEY, features); diff --git a/apps/backend/src/application/dto/carrier-documents.dto.ts b/apps/backend/src/application/dto/carrier-documents.dto.ts index 7bdb79a..71f038b 100644 --- a/apps/backend/src/application/dto/carrier-documents.dto.ts +++ b/apps/backend/src/application/dto/carrier-documents.dto.ts @@ -1,112 +1,118 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsNotEmpty, IsString } from 'class-validator'; - -/** - * DTO for verifying document access password - */ -export class VerifyDocumentAccessDto { - @ApiProperty({ description: 'Password for document access (booking number code)' }) - @IsString() - @IsNotEmpty() - password: string; -} - -/** - * Response DTO for checking document access requirements - */ -export class DocumentAccessRequirementsDto { - @ApiProperty({ description: 'Whether password is required to access documents' }) - requiresPassword: boolean; - - @ApiPropertyOptional({ description: 'Booking number (if available)' }) - bookingNumber?: string; - - @ApiProperty({ description: 'Current booking status' }) - status: string; -} - -/** - * Booking Summary DTO for Carrier Documents Page - */ -export class BookingSummaryDto { - @ApiProperty({ description: 'Booking unique ID' }) - id: string; - - @ApiPropertyOptional({ description: 'Human-readable booking number' }) - bookingNumber?: string; - - @ApiProperty({ description: 'Carrier/Company name' }) - carrierName: string; - - @ApiProperty({ description: 'Origin port code' }) - origin: string; - - @ApiProperty({ description: 'Destination port code' }) - destination: string; - - @ApiProperty({ description: 'Route description (origin -> destination)' }) - routeDescription: string; - - @ApiProperty({ description: 'Volume in CBM' }) - volumeCBM: number; - - @ApiProperty({ description: 'Weight in KG' }) - weightKG: number; - - @ApiProperty({ description: 'Number of pallets' }) - palletCount: number; - - @ApiProperty({ description: 'Price in the primary currency' }) - price: number; - - @ApiProperty({ description: 'Currency (USD or EUR)' }) - currency: string; - - @ApiProperty({ description: 'Transit time in days' }) - transitDays: number; - - @ApiProperty({ description: 'Container type' }) - containerType: string; - - @ApiProperty({ description: 'When the booking was accepted' }) - acceptedAt: Date; -} - -/** - * Document with signed download URL for carrier access - */ -export class DocumentWithUrlDto { - @ApiProperty({ description: 'Document unique ID' }) - id: string; - - @ApiProperty({ - description: 'Document type', - enum: ['BILL_OF_LADING', 'PACKING_LIST', 'COMMERCIAL_INVOICE', 'CERTIFICATE_OF_ORIGIN', 'OTHER'], - }) - type: string; - - @ApiProperty({ description: 'Original file name' }) - fileName: string; - - @ApiProperty({ description: 'File MIME type' }) - mimeType: string; - - @ApiProperty({ description: 'File size in bytes' }) - size: number; - - @ApiProperty({ description: 'Temporary signed download URL (valid for 1 hour)' }) - downloadUrl: string; -} - -/** - * Carrier Documents Response DTO - * - * Response for carrier document access page - */ -export class CarrierDocumentsResponseDto { - @ApiProperty({ description: 'Booking summary information', type: BookingSummaryDto }) - booking: BookingSummaryDto; - - @ApiProperty({ description: 'List of documents with download URLs', type: [DocumentWithUrlDto] }) - documents: DocumentWithUrlDto[]; -} +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsNotEmpty, IsString } from 'class-validator'; + +/** + * DTO for verifying document access password + */ +export class VerifyDocumentAccessDto { + @ApiProperty({ description: 'Password for document access (booking number code)' }) + @IsString() + @IsNotEmpty() + password: string; +} + +/** + * Response DTO for checking document access requirements + */ +export class DocumentAccessRequirementsDto { + @ApiProperty({ description: 'Whether password is required to access documents' }) + requiresPassword: boolean; + + @ApiPropertyOptional({ description: 'Booking number (if available)' }) + bookingNumber?: string; + + @ApiProperty({ description: 'Current booking status' }) + status: string; +} + +/** + * Booking Summary DTO for Carrier Documents Page + */ +export class BookingSummaryDto { + @ApiProperty({ description: 'Booking unique ID' }) + id: string; + + @ApiPropertyOptional({ description: 'Human-readable booking number' }) + bookingNumber?: string; + + @ApiProperty({ description: 'Carrier/Company name' }) + carrierName: string; + + @ApiProperty({ description: 'Origin port code' }) + origin: string; + + @ApiProperty({ description: 'Destination port code' }) + destination: string; + + @ApiProperty({ description: 'Route description (origin -> destination)' }) + routeDescription: string; + + @ApiProperty({ description: 'Volume in CBM' }) + volumeCBM: number; + + @ApiProperty({ description: 'Weight in KG' }) + weightKG: number; + + @ApiProperty({ description: 'Number of pallets' }) + palletCount: number; + + @ApiProperty({ description: 'Price in the primary currency' }) + price: number; + + @ApiProperty({ description: 'Currency (USD or EUR)' }) + currency: string; + + @ApiProperty({ description: 'Transit time in days' }) + transitDays: number; + + @ApiProperty({ description: 'Container type' }) + containerType: string; + + @ApiProperty({ description: 'When the booking was accepted' }) + acceptedAt: Date; +} + +/** + * Document with signed download URL for carrier access + */ +export class DocumentWithUrlDto { + @ApiProperty({ description: 'Document unique ID' }) + id: string; + + @ApiProperty({ + description: 'Document type', + enum: [ + 'BILL_OF_LADING', + 'PACKING_LIST', + 'COMMERCIAL_INVOICE', + 'CERTIFICATE_OF_ORIGIN', + 'OTHER', + ], + }) + type: string; + + @ApiProperty({ description: 'Original file name' }) + fileName: string; + + @ApiProperty({ description: 'File MIME type' }) + mimeType: string; + + @ApiProperty({ description: 'File size in bytes' }) + size: number; + + @ApiProperty({ description: 'Temporary signed download URL (valid for 1 hour)' }) + downloadUrl: string; +} + +/** + * Carrier Documents Response DTO + * + * Response for carrier document access page + */ +export class CarrierDocumentsResponseDto { + @ApiProperty({ description: 'Booking summary information', type: BookingSummaryDto }) + booking: BookingSummaryDto; + + @ApiProperty({ description: 'List of documents with download URLs', type: [DocumentWithUrlDto] }) + documents: DocumentWithUrlDto[]; +} diff --git a/apps/backend/src/application/dto/consent.dto.ts b/apps/backend/src/application/dto/consent.dto.ts index fa3b883..741e720 100644 --- a/apps/backend/src/application/dto/consent.dto.ts +++ b/apps/backend/src/application/dto/consent.dto.ts @@ -1,139 +1,139 @@ -/** - * Cookie Consent DTOs - * GDPR compliant consent management - */ - -import { IsBoolean, IsOptional, IsString, IsEnum, IsDateString, IsIP } from 'class-validator'; -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; - -/** - * Request DTO for recording/updating cookie consent - */ -export class UpdateConsentDto { - @ApiProperty({ - example: true, - description: 'Essential cookies consent (always true, required for functionality)', - default: true, - }) - @IsBoolean() - essential: boolean; - - @ApiProperty({ - example: false, - description: 'Functional cookies consent (preferences, language, etc.)', - default: false, - }) - @IsBoolean() - functional: boolean; - - @ApiProperty({ - example: false, - description: 'Analytics cookies consent (Google Analytics, Sentry, etc.)', - default: false, - }) - @IsBoolean() - analytics: boolean; - - @ApiProperty({ - example: false, - description: 'Marketing cookies consent (ads, tracking, remarketing)', - default: false, - }) - @IsBoolean() - marketing: boolean; - - @ApiPropertyOptional({ - example: '192.168.1.1', - description: 'IP address at time of consent (for GDPR audit trail)', - }) - @IsOptional() - @IsString() - ipAddress?: string; - - @ApiPropertyOptional({ - example: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', - description: 'User agent at time of consent', - }) - @IsOptional() - @IsString() - userAgent?: string; -} - -/** - * Response DTO for consent status - */ -export class ConsentResponseDto { - @ApiProperty({ - example: '550e8400-e29b-41d4-a716-446655440000', - description: 'User ID', - }) - userId: string; - - @ApiProperty({ - example: true, - description: 'Essential cookies consent (always true)', - }) - essential: boolean; - - @ApiProperty({ - example: false, - description: 'Functional cookies consent', - }) - functional: boolean; - - @ApiProperty({ - example: false, - description: 'Analytics cookies consent', - }) - analytics: boolean; - - @ApiProperty({ - example: false, - description: 'Marketing cookies consent', - }) - marketing: boolean; - - @ApiProperty({ - example: '2025-01-27T10:30:00.000Z', - description: 'Date when consent was recorded', - }) - consentDate: Date; - - @ApiProperty({ - example: '2025-01-27T10:30:00.000Z', - description: 'Last update timestamp', - }) - updatedAt: Date; -} - -/** - * Request DTO for withdrawing specific consent - */ -export class WithdrawConsentDto { - @ApiProperty({ - example: 'marketing', - description: 'Type of consent to withdraw', - enum: ['functional', 'analytics', 'marketing'], - }) - @IsEnum(['functional', 'analytics', 'marketing'], { - message: 'Consent type must be functional, analytics, or marketing', - }) - consentType: 'functional' | 'analytics' | 'marketing'; -} - -/** - * Success response DTO - */ -export class ConsentSuccessDto { - @ApiProperty({ - example: true, - description: 'Operation success status', - }) - success: boolean; - - @ApiProperty({ - example: 'Consent preferences saved successfully', - description: 'Response message', - }) - message: string; -} +/** + * Cookie Consent DTOs + * GDPR compliant consent management + */ + +import { IsBoolean, IsOptional, IsString, IsEnum } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +/** + * Request DTO for recording/updating cookie consent + */ +export class UpdateConsentDto { + @ApiProperty({ + example: true, + description: 'Essential cookies consent (always true, required for functionality)', + default: true, + }) + @IsBoolean() + essential: boolean; + + @ApiProperty({ + example: false, + description: 'Functional cookies consent (preferences, language, etc.)', + default: false, + }) + @IsBoolean() + functional: boolean; + + @ApiProperty({ + example: false, + description: 'Analytics cookies consent (Google Analytics, Sentry, etc.)', + default: false, + }) + @IsBoolean() + analytics: boolean; + + @ApiProperty({ + example: false, + description: 'Marketing cookies consent (ads, tracking, remarketing)', + default: false, + }) + @IsBoolean() + marketing: boolean; + + @ApiPropertyOptional({ + example: '192.168.1.1', + description: 'IP address at time of consent (for GDPR audit trail)', + }) + @IsOptional() + @IsString() + ipAddress?: string; + + @ApiPropertyOptional({ + example: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + description: 'User agent at time of consent', + }) + @IsOptional() + @IsString() + userAgent?: string; +} + +/** + * Response DTO for consent status + */ +export class ConsentResponseDto { + @ApiProperty({ + example: '550e8400-e29b-41d4-a716-446655440000', + description: 'User ID', + }) + userId: string; + + @ApiProperty({ + example: true, + description: 'Essential cookies consent (always true)', + }) + essential: boolean; + + @ApiProperty({ + example: false, + description: 'Functional cookies consent', + }) + functional: boolean; + + @ApiProperty({ + example: false, + description: 'Analytics cookies consent', + }) + analytics: boolean; + + @ApiProperty({ + example: false, + description: 'Marketing cookies consent', + }) + marketing: boolean; + + @ApiProperty({ + example: '2025-01-27T10:30:00.000Z', + description: 'Date when consent was recorded', + }) + consentDate: Date; + + @ApiProperty({ + example: '2025-01-27T10:30:00.000Z', + description: 'Last update timestamp', + }) + updatedAt: Date; +} + +/** + * Request DTO for withdrawing specific consent + */ +export class WithdrawConsentDto { + @ApiProperty({ + example: 'marketing', + description: 'Type of consent to withdraw', + enum: ['functional', 'analytics', 'marketing'], + }) + @IsEnum(['functional', 'analytics', 'marketing'], { + message: 'Consent type must be functional, analytics, or marketing', + }) + consentType: 'functional' | 'analytics' | 'marketing'; +} + +/** + * Success response DTO + */ +export class ConsentSuccessDto { + @ApiProperty({ + example: true, + description: 'Operation success status', + }) + success: boolean; + + @ApiProperty({ + example: 'Consent preferences saved successfully', + description: 'Response message', + }) + message: string; +} diff --git a/apps/backend/src/application/dto/csv-booking.dto.ts b/apps/backend/src/application/dto/csv-booking.dto.ts index d2425f3..d32f5f8 100644 --- a/apps/backend/src/application/dto/csv-booking.dto.ts +++ b/apps/backend/src/application/dto/csv-booking.dto.ts @@ -294,8 +294,8 @@ export class CsvBookingResponseDto { @ApiProperty({ description: 'Booking status', - enum: ['PENDING', 'ACCEPTED', 'REJECTED', 'CANCELLED'], - example: 'PENDING', + enum: ['PENDING_PAYMENT', 'PENDING', 'ACCEPTED', 'REJECTED', 'CANCELLED'], + example: 'PENDING_PAYMENT', }) status: string; @@ -353,6 +353,18 @@ export class CsvBookingResponseDto { example: 1850.5, }) price: number; + + @ApiPropertyOptional({ + description: 'Commission rate in percent', + example: 5, + }) + commissionRate?: number; + + @ApiPropertyOptional({ + description: 'Commission amount in EUR', + example: 313.27, + }) + commissionAmountEur?: number; } /** @@ -414,6 +426,12 @@ export class CsvBookingListResponseDto { * Statistics for user's or organization's bookings */ export class CsvBookingStatsDto { + @ApiProperty({ + description: 'Number of bookings awaiting payment', + example: 1, + }) + pendingPayment: number; + @ApiProperty({ description: 'Number of pending bookings', example: 5, diff --git a/apps/backend/src/application/dto/subscription.dto.ts b/apps/backend/src/application/dto/subscription.dto.ts index a8e7f75..5302528 100644 --- a/apps/backend/src/application/dto/subscription.dto.ts +++ b/apps/backend/src/application/dto/subscription.dto.ts @@ -5,25 +5,16 @@ */ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { - IsString, - IsEnum, - IsNotEmpty, - IsUrl, - IsOptional, - IsBoolean, - IsInt, - Min, -} from 'class-validator'; +import { IsString, IsEnum, IsUrl, IsOptional } from 'class-validator'; /** * Subscription plan types */ export enum SubscriptionPlanDto { - FREE = 'FREE', - STARTER = 'STARTER', - PRO = 'PRO', - ENTERPRISE = 'ENTERPRISE', + BRONZE = 'BRONZE', + SILVER = 'SILVER', + GOLD = 'GOLD', + PLATINIUM = 'PLATINIUM', } /** @@ -53,7 +44,7 @@ export enum BillingIntervalDto { */ export class CreateCheckoutSessionDto { @ApiProperty({ - example: SubscriptionPlanDto.STARTER, + example: SubscriptionPlanDto.SILVER, description: 'The subscription plan to purchase', enum: SubscriptionPlanDto, }) @@ -197,14 +188,14 @@ export class LicenseResponseDto { */ export class PlanDetailsDto { @ApiProperty({ - example: SubscriptionPlanDto.STARTER, + example: SubscriptionPlanDto.SILVER, description: 'Plan identifier', enum: SubscriptionPlanDto, }) plan: SubscriptionPlanDto; @ApiProperty({ - example: 'Starter', + example: 'Silver', description: 'Plan display name', }) name: string; @@ -216,20 +207,51 @@ export class PlanDetailsDto { maxLicenses: number; @ApiProperty({ - example: 49, + example: 249, description: 'Monthly price in EUR', }) monthlyPriceEur: number; @ApiProperty({ - example: 470, - description: 'Yearly price in EUR', + example: 2739, + description: 'Yearly price in EUR (11 months)', }) yearlyPriceEur: number; @ApiProperty({ - example: ['Up to 5 users', 'Advanced rate search', 'CSV imports'], - description: 'List of features included in this plan', + example: -1, + description: 'Maximum shipments per year (-1 for unlimited)', + }) + maxShipmentsPerYear: number; + + @ApiProperty({ + example: 3, + description: 'Commission rate percentage on shipments', + }) + commissionRatePercent: number; + + @ApiProperty({ + example: 'email', + description: 'Support level: none, email, direct, dedicated_kam', + }) + supportLevel: string; + + @ApiProperty({ + example: 'silver', + description: 'Status badge: none, silver, gold, platinium', + }) + statusBadge: string; + + @ApiProperty({ + example: ['dashboard', 'wiki', 'user_management', 'csv_export'], + description: 'List of plan feature flags', + type: [String], + }) + planFeatures: string[]; + + @ApiProperty({ + example: ["Jusqu'à 5 utilisateurs", 'Expéditions illimitées', 'Import CSV'], + description: 'List of human-readable features included in this plan', type: [String], }) features: string[]; @@ -252,7 +274,7 @@ export class SubscriptionResponseDto { organizationId: string; @ApiProperty({ - example: SubscriptionPlanDto.STARTER, + example: SubscriptionPlanDto.SILVER, description: 'Current subscription plan', enum: SubscriptionPlanDto, }) diff --git a/apps/backend/src/application/guards/feature-flag.guard.ts b/apps/backend/src/application/guards/feature-flag.guard.ts new file mode 100644 index 0000000..5aae493 --- /dev/null +++ b/apps/backend/src/application/guards/feature-flag.guard.ts @@ -0,0 +1,103 @@ +import { + Injectable, + CanActivate, + ExecutionContext, + ForbiddenException, + Inject, + Logger, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { PlanFeature } from '@domain/value-objects/plan-feature.vo'; +import { + SubscriptionRepository, + SUBSCRIPTION_REPOSITORY, +} from '@domain/ports/out/subscription.repository'; +import { REQUIRED_FEATURES_KEY } from '../decorators/requires-feature.decorator'; + +/** + * Feature Flag Guard + * + * Checks if the user's subscription plan includes the required features. + * First tries to read plan from JWT payload (fast path), falls back to DB lookup. + * + * Usage: + * @UseGuards(JwtAuthGuard, RolesGuard, FeatureFlagGuard) + * @RequiresFeature('dashboard') + */ +@Injectable() +export class FeatureFlagGuard implements CanActivate { + private readonly logger = new Logger(FeatureFlagGuard.name); + + constructor( + private readonly reflector: Reflector, + @Inject(SUBSCRIPTION_REPOSITORY) + private readonly subscriptionRepository: SubscriptionRepository + ) {} + + async canActivate(context: ExecutionContext): Promise { + // Get required features from @RequiresFeature() decorator + const requiredFeatures = this.reflector.getAllAndOverride( + REQUIRED_FEATURES_KEY, + [context.getHandler(), context.getClass()] + ); + + // If no features are required, allow access + if (!requiredFeatures || requiredFeatures.length === 0) { + return true; + } + + const request = context.switchToHttp().getRequest(); + const user = request.user; + + if (!user || !user.organizationId) { + return false; + } + + // Fast path: check plan features from JWT payload + if (user.planFeatures && Array.isArray(user.planFeatures)) { + const hasAllFeatures = requiredFeatures.every(feature => user.planFeatures.includes(feature)); + + if (hasAllFeatures) { + return true; + } + + // JWT says no — but JWT might be stale after an upgrade. + // Fall through to DB check. + } + + // Slow path: DB lookup for fresh subscription data + try { + const subscription = await this.subscriptionRepository.findByOrganizationId( + user.organizationId + ); + + if (!subscription) { + // No subscription means Bronze (free) plan — no premium features + this.throwFeatureRequired(requiredFeatures); + } + + const plan = subscription!.plan; + const missingFeatures = requiredFeatures.filter(feature => !plan.hasFeature(feature)); + + if (missingFeatures.length > 0) { + this.throwFeatureRequired(requiredFeatures); + } + + return true; + } catch (error) { + if (error instanceof ForbiddenException) { + throw error; + } + this.logger.error(`Failed to check subscription features: ${error}`); + // On DB error, deny access to premium features rather than 500 + this.throwFeatureRequired(requiredFeatures); + } + } + + private throwFeatureRequired(features: PlanFeature[]): never { + const featureNames = features.join(', '); + throw new ForbiddenException( + `Cette fonctionnalité nécessite un plan supérieur. Fonctionnalités requises : ${featureNames}. Passez à un plan Silver ou supérieur.` + ); + } +} diff --git a/apps/backend/src/application/services/csv-booking.service.ts b/apps/backend/src/application/services/csv-booking.service.ts index 7ddf3da..3ca997b 100644 --- a/apps/backend/src/application/services/csv-booking.service.ts +++ b/apps/backend/src/application/services/csv-booking.service.ts @@ -17,6 +17,7 @@ import { } from '@domain/ports/out/notification.repository'; import { EmailPort, EMAIL_PORT } from '@domain/ports/out/email.port'; import { StoragePort, STORAGE_PORT } from '@domain/ports/out/storage.port'; +import { StripePort, STRIPE_PORT } from '@domain/ports/out/stripe.port'; import { Notification, NotificationType, @@ -30,6 +31,7 @@ import { CsvBookingStatsDto, } from '../dto/csv-booking.dto'; import { CarrierDocumentsResponseDto } from '../dto/carrier-documents.dto'; +import { SubscriptionService } from './subscription.service'; /** * CSV Booking Document (simple class for domain) @@ -62,7 +64,10 @@ export class CsvBookingService { @Inject(EMAIL_PORT) private readonly emailAdapter: EmailPort, @Inject(STORAGE_PORT) - private readonly storageAdapter: StoragePort + private readonly storageAdapter: StoragePort, + @Inject(STRIPE_PORT) + private readonly stripeAdapter: StripePort, + private readonly subscriptionService: SubscriptionService ) {} /** @@ -114,7 +119,18 @@ export class CsvBookingService { // Upload documents to S3 const documents = await this.uploadDocuments(files, bookingId); - // Create domain entity + // Calculate commission based on organization's subscription plan + let commissionRate = 5; // default Bronze + let commissionAmountEur = 0; + try { + const subscription = await this.subscriptionService.getOrCreateSubscription(organizationId); + commissionRate = subscription.plan.commissionRatePercent; + } catch (error: any) { + this.logger.error(`Failed to get subscription for commission: ${error?.message}`); + } + commissionAmountEur = Math.round(dto.priceEUR * commissionRate) / 100; + + // Create domain entity in PENDING_PAYMENT status (no email sent yet) const booking = new CsvBooking( bookingId, userId, @@ -131,12 +147,16 @@ export class CsvBookingService { dto.primaryCurrency, dto.transitDays, dto.containerType, - CsvBookingStatus.PENDING, + CsvBookingStatus.PENDING_PAYMENT, documents, confirmationToken, new Date(), undefined, - dto.notes + dto.notes, + undefined, + bookingNumber, + commissionRate, + commissionAmountEur ); // Save to database @@ -152,58 +172,173 @@ export class CsvBookingService { await this.csvBookingRepository['repository'].save(ormBooking); } - this.logger.log(`CSV booking created with ID: ${bookingId}, number: ${bookingNumber}`); + this.logger.log( + `CSV booking created with ID: ${bookingId}, number: ${bookingNumber}, status: PENDING_PAYMENT, commission: ${commissionRate}% = ${commissionAmountEur}€` + ); - // Send email to carrier and WAIT for confirmation - // The button waits for the email to be sent before responding + // NO email sent to carrier yet - will be sent after commission payment + // NO notification yet - will be created after payment confirmation + + return this.toResponseDto(savedBooking); + } + + /** + * Create a Stripe Checkout session for commission payment + */ + async createCommissionPayment( + bookingId: string, + userId: string, + userEmail: string, + frontendUrl: string + ): Promise<{ sessionUrl: string; sessionId: string; commissionAmountEur: number }> { + const booking = await this.csvBookingRepository.findById(bookingId); + + if (!booking) { + throw new NotFoundException(`Booking with ID ${bookingId} not found`); + } + + if (booking.userId !== userId) { + throw new NotFoundException(`Booking with ID ${bookingId} not found`); + } + + if (booking.status !== CsvBookingStatus.PENDING_PAYMENT) { + throw new BadRequestException( + `Booking is not awaiting payment. Current status: ${booking.status}` + ); + } + + const commissionAmountEur = booking.commissionAmountEur || 0; + if (commissionAmountEur <= 0) { + throw new BadRequestException('Commission amount is invalid'); + } + + const amountCents = Math.round(commissionAmountEur * 100); + + const result = await this.stripeAdapter.createCommissionCheckout({ + bookingId: booking.id, + amountCents, + currency: 'eur', + customerEmail: userEmail, + organizationId: booking.organizationId, + bookingDescription: `Commission booking ${booking.bookingNumber || booking.id} - ${booking.origin.getValue()} → ${booking.destination.getValue()}`, + successUrl: `${frontendUrl}/dashboard/booking/${booking.id}/payment-success?session_id={CHECKOUT_SESSION_ID}`, + cancelUrl: `${frontendUrl}/dashboard/booking/${booking.id}/pay`, + }); + + this.logger.log( + `Created Stripe commission checkout for booking ${bookingId}: ${amountCents} cents EUR` + ); + + return { + sessionUrl: result.sessionUrl, + sessionId: result.sessionId, + commissionAmountEur, + }; + } + + /** + * Confirm commission payment and activate booking + * Called after Stripe redirect with session_id + */ + async confirmCommissionPayment( + bookingId: string, + sessionId: string, + userId: string + ): Promise { + const booking = await this.csvBookingRepository.findById(bookingId); + + if (!booking) { + throw new NotFoundException(`Booking with ID ${bookingId} not found`); + } + + if (booking.userId !== userId) { + throw new NotFoundException(`Booking with ID ${bookingId} not found`); + } + + if (booking.status !== CsvBookingStatus.PENDING_PAYMENT) { + // Already confirmed - return current state + if (booking.status === CsvBookingStatus.PENDING) { + return this.toResponseDto(booking); + } + throw new BadRequestException( + `Booking is not awaiting payment. Current status: ${booking.status}` + ); + } + + // Verify payment with Stripe + const session = await this.stripeAdapter.getCheckoutSession(sessionId); + if (!session || session.status !== 'complete') { + throw new BadRequestException('Payment has not been completed'); + } + + // Verify the session is for this booking + if (session.metadata?.bookingId !== bookingId) { + throw new BadRequestException('Payment session does not match this booking'); + } + + // Transition to PENDING + booking.markPaymentCompleted(); + booking.stripePaymentIntentId = sessionId; + + // Save updated booking + const updatedBooking = await this.csvBookingRepository.update(booking); + this.logger.log(`Booking ${bookingId} payment confirmed, status now PENDING`); + + // Get ORM entity for booking number + const ormBooking = await this.csvBookingRepository['repository'].findOne({ + where: { id: bookingId }, + }); + const bookingNumber = ormBooking?.bookingNumber; + const documentPassword = bookingNumber + ? this.extractPasswordFromBookingNumber(bookingNumber) + : undefined; + + // NOW send email to carrier try { - await this.emailAdapter.sendCsvBookingRequest(dto.carrierEmail, { - bookingId, - bookingNumber, - documentPassword, - origin: dto.origin, - destination: dto.destination, - volumeCBM: dto.volumeCBM, - weightKG: dto.weightKG, - palletCount: dto.palletCount, - priceUSD: dto.priceUSD, - priceEUR: dto.priceEUR, - primaryCurrency: dto.primaryCurrency, - transitDays: dto.transitDays, - containerType: dto.containerType, - documents: documents.map(doc => ({ + await this.emailAdapter.sendCsvBookingRequest(booking.carrierEmail, { + bookingId: booking.id, + bookingNumber: bookingNumber || '', + documentPassword: documentPassword || '', + origin: booking.origin.getValue(), + destination: booking.destination.getValue(), + volumeCBM: booking.volumeCBM, + weightKG: booking.weightKG, + palletCount: booking.palletCount, + priceUSD: booking.priceUSD, + priceEUR: booking.priceEUR, + primaryCurrency: booking.primaryCurrency, + transitDays: booking.transitDays, + containerType: booking.containerType, + documents: booking.documents.map(doc => ({ type: doc.type, fileName: doc.fileName, })), - confirmationToken, - notes: dto.notes, + confirmationToken: booking.confirmationToken, + notes: booking.notes, }); - this.logger.log(`Email sent to carrier: ${dto.carrierEmail}`); + this.logger.log(`Email sent to carrier: ${booking.carrierEmail}`); } catch (error: any) { this.logger.error(`Failed to send email to carrier: ${error?.message}`, error?.stack); - // Continue even if email fails - booking is already saved } // Create notification for user try { const notification = Notification.create({ id: uuidv4(), - userId, - organizationId, + userId: booking.userId, + organizationId: booking.organizationId, type: NotificationType.CSV_BOOKING_REQUEST_SENT, priority: NotificationPriority.MEDIUM, title: 'Booking Request Sent', - message: `Your booking request to ${dto.carrierName} for ${dto.origin} → ${dto.destination} has been sent successfully.`, - metadata: { bookingId, carrierName: dto.carrierName }, + message: `Your booking request to ${booking.carrierName} for ${booking.getRouteDescription()} has been sent successfully after payment.`, + metadata: { bookingId: booking.id, carrierName: booking.carrierName }, }); await this.notificationRepository.save(notification); - this.logger.log(`Notification created for user ${userId}`); } catch (error: any) { this.logger.error(`Failed to create notification: ${error?.message}`, error?.stack); - // Continue even if notification fails } - return this.toResponseDto(savedBooking); + return this.toResponseDto(updatedBooking); } /** @@ -394,6 +529,21 @@ export class CsvBookingService { // Accept the booking (domain logic validates status) booking.accept(); + // Apply commission based on organization's subscription plan + try { + const subscription = await this.subscriptionService.getOrCreateSubscription( + booking.organizationId + ); + const commissionRate = subscription.plan.commissionRatePercent; + const baseAmountEur = booking.priceEUR; + booking.applyCommission(commissionRate, baseAmountEur); + this.logger.log( + `Commission applied: ${commissionRate}% on ${baseAmountEur}€ = ${booking.commissionAmountEur}€` + ); + } catch (error: any) { + this.logger.error(`Failed to apply commission: ${error?.message}`, error?.stack); + } + // Save updated booking const updatedBooking = await this.csvBookingRepository.update(booking); this.logger.log(`Booking ${booking.id} accepted`); @@ -568,6 +718,7 @@ export class CsvBookingService { const stats = await this.csvBookingRepository.countByStatusForUser(userId); return { + pendingPayment: stats[CsvBookingStatus.PENDING_PAYMENT] || 0, pending: stats[CsvBookingStatus.PENDING] || 0, accepted: stats[CsvBookingStatus.ACCEPTED] || 0, rejected: stats[CsvBookingStatus.REJECTED] || 0, @@ -583,6 +734,7 @@ export class CsvBookingService { const stats = await this.csvBookingRepository.countByStatusForOrganization(organizationId); return { + pendingPayment: stats[CsvBookingStatus.PENDING_PAYMENT] || 0, pending: stats[CsvBookingStatus.PENDING] || 0, accepted: stats[CsvBookingStatus.ACCEPTED] || 0, rejected: stats[CsvBookingStatus.REJECTED] || 0, @@ -678,9 +830,15 @@ export class CsvBookingService { throw new NotFoundException(`Booking with ID ${bookingId} not found`); } - // Allow adding documents to PENDING or ACCEPTED bookings - if (booking.status !== CsvBookingStatus.PENDING && booking.status !== CsvBookingStatus.ACCEPTED) { - throw new BadRequestException('Cannot add documents to a booking that is rejected or cancelled'); + // Allow adding documents to PENDING_PAYMENT, PENDING, or ACCEPTED bookings + if ( + booking.status !== CsvBookingStatus.PENDING_PAYMENT && + booking.status !== CsvBookingStatus.PENDING && + booking.status !== CsvBookingStatus.ACCEPTED + ) { + throw new BadRequestException( + 'Cannot add documents to a booking that is rejected or cancelled' + ); } // Upload new documents @@ -723,7 +881,10 @@ export class CsvBookingService { }); this.logger.log(`New documents notification sent to carrier: ${booking.carrierEmail}`); } catch (error: any) { - this.logger.error(`Failed to send new documents notification: ${error?.message}`, error?.stack); + this.logger.error( + `Failed to send new documents notification: ${error?.message}`, + error?.stack + ); } } @@ -755,8 +916,11 @@ export class CsvBookingService { throw new NotFoundException(`Booking with ID ${bookingId} not found`); } - // Verify booking is still pending - if (booking.status !== CsvBookingStatus.PENDING) { + // Verify booking is still pending or awaiting payment + if ( + booking.status !== CsvBookingStatus.PENDING_PAYMENT && + booking.status !== CsvBookingStatus.PENDING + ) { throw new BadRequestException('Cannot delete documents from a booking that is not pending'); } @@ -871,7 +1035,9 @@ export class CsvBookingService { await this.csvBookingRepository['repository'].save(ormBooking); } - this.logger.log(`Replaced document ${documentId} with ${newDocument.id} in booking ${bookingId}`); + this.logger.log( + `Replaced document ${documentId} with ${newDocument.id} in booking ${bookingId}` + ); return { success: true, @@ -947,6 +1113,8 @@ export class CsvBookingService { routeDescription: booking.getRouteDescription(), isExpired: booking.isExpired(), price: booking.getPriceInCurrency(primaryCurrency), + commissionRate: booking.commissionRate, + commissionAmountEur: booking.commissionAmountEur, }; } diff --git a/apps/backend/src/application/services/gdpr.service.ts b/apps/backend/src/application/services/gdpr.service.ts index b2d8541..d7784d2 100644 --- a/apps/backend/src/application/services/gdpr.service.ts +++ b/apps/backend/src/application/services/gdpr.service.ts @@ -120,10 +120,7 @@ export class GDPRService { /** * Record or update consent (GDPR Article 7 - Conditions for consent) */ - async recordConsent( - userId: string, - consentData: UpdateConsentDto - ): Promise { + async recordConsent(userId: string, consentData: UpdateConsentDto): Promise { this.logger.log(`Recording consent for user ${userId}`); // Verify user exists diff --git a/apps/backend/src/application/services/invitation.service.ts b/apps/backend/src/application/services/invitation.service.ts index 1dc6d6e..e4b4a6a 100644 --- a/apps/backend/src/application/services/invitation.service.ts +++ b/apps/backend/src/application/services/invitation.service.ts @@ -38,7 +38,7 @@ export class InvitationService { @Inject(EMAIL_PORT) private readonly emailService: EmailPort, private readonly configService: ConfigService, - private readonly subscriptionService: SubscriptionService, + private readonly subscriptionService: SubscriptionService ) {} /** @@ -72,11 +72,11 @@ export class InvitationService { const canInviteResult = await this.subscriptionService.canInviteUser(organizationId); if (!canInviteResult.canInvite) { this.logger.warn( - `License limit reached for organization ${organizationId}: ${canInviteResult.usedLicenses}/${canInviteResult.maxLicenses}`, + `License limit reached for organization ${organizationId}: ${canInviteResult.usedLicenses}/${canInviteResult.maxLicenses}` ); throw new ForbiddenException( canInviteResult.message || - `License limit reached. Please upgrade your subscription to invite more users.`, + `License limit reached. Please upgrade your subscription to invite more users.` ); } diff --git a/apps/backend/src/application/services/subscription.service.ts b/apps/backend/src/application/services/subscription.service.ts index 90951de..061949f 100644 --- a/apps/backend/src/application/services/subscription.service.ts +++ b/apps/backend/src/application/services/subscription.service.ts @@ -4,24 +4,14 @@ * Business logic for subscription and license management. */ -import { - Injectable, - Inject, - Logger, - NotFoundException, - BadRequestException, - ForbiddenException, -} from '@nestjs/common'; +import { Injectable, Inject, Logger, NotFoundException, BadRequestException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { v4 as uuidv4 } from 'uuid'; import { SubscriptionRepository, SUBSCRIPTION_REPOSITORY, } from '@domain/ports/out/subscription.repository'; -import { - LicenseRepository, - LICENSE_REPOSITORY, -} from '@domain/ports/out/license.repository'; +import { LicenseRepository, LICENSE_REPOSITORY } from '@domain/ports/out/license.repository'; import { OrganizationRepository, ORGANIZATION_REPOSITORY, @@ -30,14 +20,10 @@ import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.reposito import { StripePort, STRIPE_PORT } from '@domain/ports/out/stripe.port'; import { Subscription } from '@domain/entities/subscription.entity'; import { License } from '@domain/entities/license.entity'; -import { - SubscriptionPlan, - SubscriptionPlanType, -} from '@domain/value-objects/subscription-plan.vo'; +import { SubscriptionPlan, SubscriptionPlanType } from '@domain/value-objects/subscription-plan.vo'; import { SubscriptionStatus } from '@domain/value-objects/subscription-status.vo'; import { NoLicensesAvailableException, - SubscriptionNotFoundException, LicenseAlreadyAssignedException, } from '@domain/exceptions/subscription.exceptions'; import { @@ -69,32 +55,28 @@ export class SubscriptionService { private readonly userRepository: UserRepository, @Inject(STRIPE_PORT) private readonly stripeAdapter: StripePort, - private readonly configService: ConfigService, + private readonly configService: ConfigService ) {} /** * Get subscription overview for an organization */ - async getSubscriptionOverview( - organizationId: string, - ): Promise { + async getSubscriptionOverview(organizationId: string): Promise { const subscription = await this.getOrCreateSubscription(organizationId); - const activeLicenses = await this.licenseRepository.findActiveBySubscriptionId( - subscription.id, - ); + const activeLicenses = await this.licenseRepository.findActiveBySubscriptionId(subscription.id); // Enrich licenses with user information const enrichedLicenses = await Promise.all( - activeLicenses.map(async (license) => { + activeLicenses.map(async license => { const user = await this.userRepository.findById(license.userId); return this.mapLicenseToDto(license, user); - }), + }) ); // Count only non-ADMIN licenses for quota calculation // ADMIN users have unlimited licenses and don't count against the quota const usedLicenses = await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins( - subscription.id, + subscription.id ); const maxLicenses = subscription.maxLicenses; const availableLicenses = subscription.isUnlimited() @@ -123,9 +105,7 @@ export class SubscriptionService { * Get all available plans */ getAllPlans(): AllPlansResponseDto { - const plans = SubscriptionPlan.getAllPlans().map((plan) => - this.mapPlanToDto(plan), - ); + const plans = SubscriptionPlan.getAllPlans().map(plan => this.mapPlanToDto(plan)); return { plans }; } @@ -137,13 +117,12 @@ export class SubscriptionService { const subscription = await this.getOrCreateSubscription(organizationId); // Count only non-ADMIN licenses - ADMIN users have unlimited licenses const usedLicenses = await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins( - subscription.id, + subscription.id ); const maxLicenses = subscription.maxLicenses; const canInvite = - subscription.isActive() && - (subscription.isUnlimited() || usedLicenses < maxLicenses); + subscription.isActive() && (subscription.isUnlimited() || usedLicenses < maxLicenses); const availableLicenses = subscription.isUnlimited() ? -1 @@ -171,7 +150,7 @@ export class SubscriptionService { async createCheckoutSession( organizationId: string, userId: string, - dto: CreateCheckoutSessionDto, + dto: CreateCheckoutSessionDto ): Promise { const organization = await this.organizationRepository.findById(organizationId); if (!organization) { @@ -184,23 +163,19 @@ export class SubscriptionService { } // Cannot checkout for FREE plan - if (dto.plan === SubscriptionPlanDto.FREE) { - throw new BadRequestException('Cannot create checkout session for FREE plan'); + if (dto.plan === SubscriptionPlanDto.BRONZE) { + throw new BadRequestException('Cannot create checkout session for Bronze plan'); } const subscription = await this.getOrCreateSubscription(organizationId); - const frontendUrl = this.configService.get( - 'FRONTEND_URL', - 'http://localhost:3000', - ); + const frontendUrl = this.configService.get('FRONTEND_URL', 'http://localhost:3000'); // Include {CHECKOUT_SESSION_ID} placeholder - Stripe replaces it with actual session ID const successUrl = dto.successUrl || `${frontendUrl}/dashboard/settings/organization?success=true&session_id={CHECKOUT_SESSION_ID}`; const cancelUrl = - dto.cancelUrl || - `${frontendUrl}/dashboard/settings/organization?canceled=true`; + dto.cancelUrl || `${frontendUrl}/dashboard/settings/organization?canceled=true`; const result = await this.stripeAdapter.createCheckoutSession({ organizationId, @@ -214,7 +189,7 @@ export class SubscriptionService { }); this.logger.log( - `Created checkout session for organization ${organizationId}, plan ${dto.plan}`, + `Created checkout session for organization ${organizationId}, plan ${dto.plan}` ); return { @@ -228,24 +203,18 @@ export class SubscriptionService { */ async createPortalSession( organizationId: string, - dto: CreatePortalSessionDto, + dto: CreatePortalSessionDto ): Promise { - const subscription = await this.subscriptionRepository.findByOrganizationId( - organizationId, - ); + const subscription = await this.subscriptionRepository.findByOrganizationId(organizationId); if (!subscription?.stripeCustomerId) { throw new BadRequestException( - 'No Stripe customer found for this organization. Please complete a checkout first.', + 'No Stripe customer found for this organization. Please complete a checkout first.' ); } - const frontendUrl = this.configService.get( - 'FRONTEND_URL', - 'http://localhost:3000', - ); - const returnUrl = - dto.returnUrl || `${frontendUrl}/dashboard/settings/organization`; + const frontendUrl = this.configService.get('FRONTEND_URL', 'http://localhost:3000'); + const returnUrl = dto.returnUrl || `${frontendUrl}/dashboard/settings/organization`; const result = await this.stripeAdapter.createPortalSession({ customerId: subscription.stripeCustomerId, @@ -267,11 +236,9 @@ export class SubscriptionService { */ async syncFromStripe( organizationId: string, - sessionId?: string, + sessionId?: string ): Promise { - let subscription = await this.subscriptionRepository.findByOrganizationId( - organizationId, - ); + let subscription = await this.subscriptionRepository.findByOrganizationId(organizationId); if (!subscription) { subscription = await this.getOrCreateSubscription(organizationId); @@ -283,12 +250,14 @@ export class SubscriptionService { // If we have a session ID, ALWAYS retrieve the checkout session to get the latest subscription details // This is important for upgrades where Stripe may create a new subscription if (sessionId) { - this.logger.log(`Retrieving checkout session ${sessionId} for organization ${organizationId}`); + this.logger.log( + `Retrieving checkout session ${sessionId} for organization ${organizationId}` + ); const checkoutSession = await this.stripeAdapter.getCheckoutSession(sessionId); if (checkoutSession) { this.logger.log( - `Checkout session found: subscriptionId=${checkoutSession.subscriptionId}, customerId=${checkoutSession.customerId}, status=${checkoutSession.status}`, + `Checkout session found: subscriptionId=${checkoutSession.subscriptionId}, customerId=${checkoutSession.customerId}, status=${checkoutSession.status}` ); // Always use the subscription ID from the checkout session if available @@ -330,7 +299,7 @@ export class SubscriptionService { if (plan) { // Count only non-ADMIN licenses - ADMIN users have unlimited licenses const usedLicenses = await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins( - subscription.id, + subscription.id ); const newPlan = SubscriptionPlan.create(plan); @@ -354,13 +323,13 @@ export class SubscriptionService { // Update status updatedSubscription = updatedSubscription.updateStatus( - SubscriptionStatus.fromStripeStatus(stripeData.status), + SubscriptionStatus.fromStripeStatus(stripeData.status) ); await this.subscriptionRepository.save(updatedSubscription); this.logger.log( - `Synced subscription for organization ${organizationId} from Stripe (plan: ${updatedSubscription.plan.value})`, + `Synced subscription for organization ${organizationId} from Stripe (plan: ${updatedSubscription.plan.value})` ); return this.getSubscriptionOverview(organizationId); @@ -418,14 +387,14 @@ export class SubscriptionService { if (!isAdmin) { // Count only non-ADMIN licenses for quota check const usedLicenses = await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins( - subscription.id, + subscription.id ); if (!subscription.canAllocateLicenses(usedLicenses)) { throw new NoLicensesAvailableException( organizationId, usedLicenses, - subscription.maxLicenses, + subscription.maxLicenses ); } } @@ -474,22 +443,18 @@ export class SubscriptionService { * Get or create a subscription for an organization */ async getOrCreateSubscription(organizationId: string): Promise { - let subscription = await this.subscriptionRepository.findByOrganizationId( - organizationId, - ); + let subscription = await this.subscriptionRepository.findByOrganizationId(organizationId); if (!subscription) { // Create FREE subscription for the organization subscription = Subscription.create({ id: uuidv4(), organizationId, - plan: SubscriptionPlan.free(), + plan: SubscriptionPlan.bronze(), }); subscription = await this.subscriptionRepository.save(subscription); - this.logger.log( - `Created FREE subscription for organization ${organizationId}`, - ); + this.logger.log(`Created Bronze subscription for organization ${organizationId}`); } return subscription; @@ -497,9 +462,7 @@ export class SubscriptionService { // Private helper methods - private async handleCheckoutCompleted( - session: Record, - ): Promise { + private async handleCheckoutCompleted(session: Record): Promise { const metadata = session.metadata as Record | undefined; const organizationId = metadata?.organizationId; const customerId = session.customer as string; @@ -537,27 +500,26 @@ export class SubscriptionService { }); subscription = subscription.updatePlan( SubscriptionPlan.create(plan), - await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins(subscription.id), + await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins(subscription.id) ); subscription = subscription.updateStatus( - SubscriptionStatus.fromStripeStatus(stripeSubscription.status), + SubscriptionStatus.fromStripeStatus(stripeSubscription.status) ); await this.subscriptionRepository.save(subscription); - this.logger.log( - `Updated subscription for organization ${organizationId} to plan ${plan}`, - ); + // Update organization status badge to match the plan + await this.updateOrganizationBadge(organizationId, subscription.statusBadge); + + this.logger.log(`Updated subscription for organization ${organizationId} to plan ${plan}`); } private async handleSubscriptionUpdated( - stripeSubscription: Record, + stripeSubscription: Record ): Promise { const subscriptionId = stripeSubscription.id as string; - let subscription = await this.subscriptionRepository.findByStripeSubscriptionId( - subscriptionId, - ); + let subscription = await this.subscriptionRepository.findByStripeSubscriptionId(subscriptionId); if (!subscription) { this.logger.warn(`Subscription ${subscriptionId} not found in database`); @@ -576,7 +538,7 @@ export class SubscriptionService { if (plan) { // Count only non-ADMIN licenses - ADMIN users have unlimited licenses const usedLicenses = await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins( - subscription.id, + subscription.id ); const newPlan = SubscriptionPlan.create(plan); @@ -584,9 +546,7 @@ export class SubscriptionService { if (newPlan.canAccommodateUsers(usedLicenses)) { subscription = subscription.updatePlan(newPlan, usedLicenses); } else { - this.logger.warn( - `Cannot update to plan ${plan} - would exceed license limit`, - ); + this.logger.warn(`Cannot update to plan ${plan} - would exceed license limit`); } } @@ -597,22 +557,26 @@ export class SubscriptionService { cancelAtPeriodEnd: stripeData.cancelAtPeriodEnd, }); subscription = subscription.updateStatus( - SubscriptionStatus.fromStripeStatus(stripeData.status), + SubscriptionStatus.fromStripeStatus(stripeData.status) ); await this.subscriptionRepository.save(subscription); + // Update organization status badge to match the plan + if (subscription.organizationId) { + await this.updateOrganizationBadge(subscription.organizationId, subscription.statusBadge); + } + this.logger.log(`Updated subscription ${subscriptionId}`); } private async handleSubscriptionDeleted( - stripeSubscription: Record, + stripeSubscription: Record ): Promise { const subscriptionId = stripeSubscription.id as string; - const subscription = await this.subscriptionRepository.findByStripeSubscriptionId( - subscriptionId, - ); + const subscription = + await this.subscriptionRepository.findByStripeSubscriptionId(subscriptionId); if (!subscription) { this.logger.warn(`Subscription ${subscriptionId} not found in database`); @@ -622,42 +586,41 @@ export class SubscriptionService { // Downgrade to FREE plan - count only non-ADMIN licenses const canceledSubscription = subscription .updatePlan( - SubscriptionPlan.free(), - await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins(subscription.id), + SubscriptionPlan.bronze(), + await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins(subscription.id) ) .updateStatus(SubscriptionStatus.canceled()); await this.subscriptionRepository.save(canceledSubscription); - this.logger.log(`Subscription ${subscriptionId} canceled, downgraded to FREE`); + // Reset organization badge to 'none' on cancellation + if (subscription.organizationId) { + await this.updateOrganizationBadge(subscription.organizationId, 'none'); + } + + this.logger.log(`Subscription ${subscriptionId} canceled, downgraded to Bronze`); } private async handlePaymentFailed(invoice: Record): Promise { const customerId = invoice.customer as string; - const subscription = await this.subscriptionRepository.findByStripeCustomerId( - customerId, - ); + const subscription = await this.subscriptionRepository.findByStripeCustomerId(customerId); if (!subscription) { this.logger.warn(`Subscription for customer ${customerId} not found`); return; } - const updatedSubscription = subscription.updateStatus( - SubscriptionStatus.pastDue(), - ); + const updatedSubscription = subscription.updateStatus(SubscriptionStatus.pastDue()); await this.subscriptionRepository.save(updatedSubscription); - this.logger.log( - `Subscription ${subscription.id} marked as past due due to payment failure`, - ); + this.logger.log(`Subscription ${subscription.id} marked as past due due to payment failure`); } private mapLicenseToDto( license: License, - user: { email: string; firstName: string; lastName: string; role: string } | null, + user: { email: string; firstName: string; lastName: string; role: string } | null ): LicenseResponseDto { return { id: license.id, @@ -671,6 +634,19 @@ export class SubscriptionService { }; } + private async updateOrganizationBadge(organizationId: string, badge: string): Promise { + try { + const organization = await this.organizationRepository.findById(organizationId); + if (organization) { + organization.updateStatusBadge(badge as 'none' | 'silver' | 'gold' | 'platinium'); + await this.organizationRepository.save(organization); + this.logger.log(`Updated status badge for organization ${organizationId} to ${badge}`); + } + } catch (error: any) { + this.logger.error(`Failed to update organization badge: ${error?.message}`, error?.stack); + } + } + private mapPlanToDto(plan: SubscriptionPlan): PlanDetailsDto { return { plan: plan.value as SubscriptionPlanDto, @@ -678,6 +654,11 @@ export class SubscriptionService { maxLicenses: plan.maxLicenses, monthlyPriceEur: plan.monthlyPriceEur, yearlyPriceEur: plan.yearlyPriceEur, + maxShipmentsPerYear: plan.maxShipmentsPerYear, + commissionRatePercent: plan.commissionRatePercent, + supportLevel: plan.supportLevel, + statusBadge: plan.statusBadge, + planFeatures: [...plan.planFeatures], features: [...plan.features], }; } diff --git a/apps/backend/src/application/users/users.module.ts b/apps/backend/src/application/users/users.module.ts index a5f714c..1603268 100644 --- a/apps/backend/src/application/users/users.module.ts +++ b/apps/backend/src/application/users/users.module.ts @@ -7,14 +7,13 @@ import { USER_REPOSITORY } from '@domain/ports/out/user.repository'; import { TypeOrmUserRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-user.repository'; import { UserOrmEntity } from '../../infrastructure/persistence/typeorm/entities/user.orm-entity'; import { SubscriptionsModule } from '../subscriptions/subscriptions.module'; +import { FeatureFlagGuard } from '../guards/feature-flag.guard'; @Module({ - imports: [ - TypeOrmModule.forFeature([UserOrmEntity]), - SubscriptionsModule, - ], + imports: [TypeOrmModule.forFeature([UserOrmEntity]), SubscriptionsModule], controllers: [UsersController], providers: [ + FeatureFlagGuard, { provide: USER_REPOSITORY, useClass: TypeOrmUserRepository, diff --git a/apps/backend/src/domain/entities/booking.entity.ts b/apps/backend/src/domain/entities/booking.entity.ts index ac496c2..3512c7a 100644 --- a/apps/backend/src/domain/entities/booking.entity.ts +++ b/apps/backend/src/domain/entities/booking.entity.ts @@ -50,6 +50,8 @@ export interface BookingProps { cargoDescription: string; containers: BookingContainer[]; specialInstructions?: string; + commissionRate?: number; + commissionAmountEur?: number; createdAt: Date; updatedAt: Date; } @@ -161,6 +163,14 @@ export class Booking { return this.props.specialInstructions; } + get commissionRate(): number | undefined { + return this.props.commissionRate; + } + + get commissionAmountEur(): number | undefined { + return this.props.commissionAmountEur; + } + get createdAt(): Date { return this.props.createdAt; } @@ -270,6 +280,19 @@ export class Booking { }); } + /** + * Apply commission to the booking + */ + applyCommission(ratePercent: number, baseAmountEur: number): Booking { + const commissionAmount = Math.round(baseAmountEur * ratePercent) / 100; + return new Booking({ + ...this.props, + commissionRate: ratePercent, + commissionAmountEur: commissionAmount, + updatedAt: new Date(), + }); + } + /** * Check if booking can be cancelled */ diff --git a/apps/backend/src/domain/entities/csv-booking.entity.ts b/apps/backend/src/domain/entities/csv-booking.entity.ts index 1361e0d..f23e797 100644 --- a/apps/backend/src/domain/entities/csv-booking.entity.ts +++ b/apps/backend/src/domain/entities/csv-booking.entity.ts @@ -6,6 +6,7 @@ import { PortCode } from '../value-objects/port-code.vo'; * Represents the lifecycle of a CSV-based booking request */ export enum CsvBookingStatus { + PENDING_PAYMENT = 'PENDING_PAYMENT', // Awaiting commission payment PENDING = 'PENDING', // Awaiting carrier response ACCEPTED = 'ACCEPTED', // Carrier accepted the booking REJECTED = 'REJECTED', // Carrier rejected the booking @@ -80,7 +81,10 @@ export class CsvBooking { public respondedAt?: Date, public notes?: string, public rejectionReason?: string, - public readonly bookingNumber?: string + public readonly bookingNumber?: string, + public commissionRate?: number, + public commissionAmountEur?: number, + public stripePaymentIntentId?: string ) { this.validate(); } @@ -144,6 +148,29 @@ export class CsvBooking { } } + /** + * Apply commission to the booking + */ + applyCommission(ratePercent: number, baseAmountEur: number): void { + this.commissionRate = ratePercent; + this.commissionAmountEur = Math.round(baseAmountEur * ratePercent) / 100; + } + + /** + * Mark commission payment as completed → transition to PENDING + * + * @throws Error if booking is not in PENDING_PAYMENT status + */ + markPaymentCompleted(): void { + if (this.status !== CsvBookingStatus.PENDING_PAYMENT) { + throw new Error( + `Cannot mark payment completed for booking with status ${this.status}. Only PENDING_PAYMENT bookings can transition.` + ); + } + + this.status = CsvBookingStatus.PENDING; + } + /** * Accept the booking * @@ -202,6 +229,10 @@ export class CsvBooking { throw new Error('Cannot cancel rejected booking'); } + if (this.status === CsvBookingStatus.CANCELLED) { + throw new Error('Booking is already cancelled'); + } + this.status = CsvBookingStatus.CANCELLED; this.respondedAt = new Date(); } @@ -211,6 +242,10 @@ export class CsvBooking { * * @returns true if booking is older than 7 days and still pending */ + isPendingPayment(): boolean { + return this.status === CsvBookingStatus.PENDING_PAYMENT; + } + isExpired(): boolean { if (this.status !== CsvBookingStatus.PENDING) { return false; @@ -363,7 +398,10 @@ export class CsvBooking { respondedAt?: Date, notes?: string, rejectionReason?: string, - bookingNumber?: string + bookingNumber?: string, + commissionRate?: number, + commissionAmountEur?: number, + stripePaymentIntentId?: string ): CsvBooking { // Create instance without calling constructor validation const booking = Object.create(CsvBooking.prototype); @@ -392,6 +430,9 @@ export class CsvBooking { booking.notes = notes; booking.rejectionReason = rejectionReason; booking.bookingNumber = bookingNumber; + booking.commissionRate = commissionRate; + booking.commissionAmountEur = commissionAmountEur; + booking.stripePaymentIntentId = stripePaymentIntentId; return booking; } diff --git a/apps/backend/src/domain/entities/license.entity.ts b/apps/backend/src/domain/entities/license.entity.ts index 75da6b7..e61186b 100644 --- a/apps/backend/src/domain/entities/license.entity.ts +++ b/apps/backend/src/domain/entities/license.entity.ts @@ -5,10 +5,7 @@ * Each active user in an organization consumes one license. */ -import { - LicenseStatus, - LicenseStatusType, -} from '../value-objects/license-status.vo'; +import { LicenseStatus, LicenseStatusType } from '../value-objects/license-status.vo'; export interface LicenseProps { readonly id: string; @@ -29,11 +26,7 @@ export class License { /** * Create a new license for a user */ - static create(props: { - id: string; - subscriptionId: string; - userId: string; - }): License { + static create(props: { id: string; subscriptionId: string; userId: string }): License { return new License({ id: props.id, subscriptionId: props.subscriptionId, diff --git a/apps/backend/src/domain/entities/organization.entity.ts b/apps/backend/src/domain/entities/organization.entity.ts index 32baac5..4cfa76c 100644 --- a/apps/backend/src/domain/entities/organization.entity.ts +++ b/apps/backend/src/domain/entities/organization.entity.ts @@ -44,6 +44,9 @@ export interface OrganizationProps { address: OrganizationAddress; logoUrl?: string; documents: OrganizationDocument[]; + siret?: string; + siretVerified: boolean; + statusBadge: 'none' | 'silver' | 'gold' | 'platinium'; createdAt: Date; updatedAt: Date; isActive: boolean; @@ -59,9 +62,19 @@ export class Organization { /** * Factory method to create a new Organization */ - static create(props: Omit): Organization { + static create( + props: Omit & { + siretVerified?: boolean; + statusBadge?: 'none' | 'silver' | 'gold' | 'platinium'; + } + ): Organization { const now = new Date(); + // Validate SIRET if provided + if (props.siret && !Organization.isValidSiret(props.siret)) { + throw new Error('Invalid SIRET format. Must be 14 digits.'); + } + // Validate SCAC code if provided if (props.scac && !Organization.isValidSCAC(props.scac)) { throw new Error('Invalid SCAC code format. Must be 4 uppercase letters.'); @@ -79,6 +92,8 @@ export class Organization { return new Organization({ ...props, + siretVerified: props.siretVerified ?? false, + statusBadge: props.statusBadge ?? 'none', createdAt: now, updatedAt: now, }); @@ -100,6 +115,10 @@ export class Organization { return scacPattern.test(scac); } + private static isValidSiret(siret: string): boolean { + return /^\d{14}$/.test(siret); + } + // Getters get id(): string { return this.props.id; @@ -153,6 +172,18 @@ export class Organization { return this.props.updatedAt; } + get siret(): string | undefined { + return this.props.siret; + } + + get siretVerified(): boolean { + return this.props.siretVerified; + } + + get statusBadge(): 'none' | 'silver' | 'gold' | 'platinium' { + return this.props.statusBadge; + } + get isActive(): boolean { return this.props.isActive; } @@ -183,6 +214,25 @@ export class Organization { this.props.updatedAt = new Date(); } + updateSiret(siret: string): void { + if (!Organization.isValidSiret(siret)) { + throw new Error('Invalid SIRET format. Must be 14 digits.'); + } + this.props.siret = siret; + this.props.siretVerified = false; + this.props.updatedAt = new Date(); + } + + markSiretVerified(): void { + this.props.siretVerified = true; + this.props.updatedAt = new Date(); + } + + updateStatusBadge(badge: 'none' | 'silver' | 'gold' | 'platinium'): void { + this.props.statusBadge = badge; + this.props.updatedAt = new Date(); + } + updateSiren(siren: string): void { this.props.siren = siren; this.props.updatedAt = new Date(); diff --git a/apps/backend/src/domain/entities/subscription.entity.spec.ts b/apps/backend/src/domain/entities/subscription.entity.spec.ts index 9b554de..4e93f08 100644 --- a/apps/backend/src/domain/entities/subscription.entity.spec.ts +++ b/apps/backend/src/domain/entities/subscription.entity.spec.ts @@ -272,7 +272,7 @@ describe('Subscription Entity', () => { }); expect(() => subscription.updatePlan(SubscriptionPlan.starter(), 0)).toThrow( - SubscriptionNotActiveException, + SubscriptionNotActiveException ); }); @@ -284,7 +284,7 @@ describe('Subscription Entity', () => { }); expect(() => subscription.updatePlan(SubscriptionPlan.starter(), 10)).toThrow( - InvalidSubscriptionDowngradeException, + InvalidSubscriptionDowngradeException ); }); }); diff --git a/apps/backend/src/domain/entities/subscription.entity.ts b/apps/backend/src/domain/entities/subscription.entity.ts index 572af04..3cde08c 100644 --- a/apps/backend/src/domain/entities/subscription.entity.ts +++ b/apps/backend/src/domain/entities/subscription.entity.ts @@ -5,10 +5,7 @@ * Stripe integration, and billing period information. */ -import { - SubscriptionPlan, - SubscriptionPlanType, -} from '../value-objects/subscription-plan.vo'; +import { SubscriptionPlan, SubscriptionPlanType } from '../value-objects/subscription-plan.vo'; import { SubscriptionStatus, SubscriptionStatusType, @@ -40,7 +37,7 @@ export class Subscription { } /** - * Create a new subscription (defaults to FREE plan) + * Create a new subscription (defaults to Bronze/free plan) */ static create(props: { id: string; @@ -53,7 +50,7 @@ export class Subscription { return new Subscription({ id: props.id, organizationId: props.organizationId, - plan: props.plan ?? SubscriptionPlan.free(), + plan: props.plan ?? SubscriptionPlan.bronze(), status: SubscriptionStatus.active(), stripeCustomerId: props.stripeCustomerId ?? null, stripeSubscriptionId: props.stripeSubscriptionId ?? null, @@ -68,10 +65,41 @@ export class Subscription { /** * Reconstitute from persistence */ + /** + * Check if a specific plan feature is available + */ + hasFeature(feature: import('../value-objects/plan-feature.vo').PlanFeature): boolean { + return this.props.plan.hasFeature(feature); + } + + /** + * Get the maximum shipments per year allowed + */ + get maxShipmentsPerYear(): number { + return this.props.plan.maxShipmentsPerYear; + } + + /** + * Get the commission rate for this subscription's plan + */ + get commissionRatePercent(): number { + return this.props.plan.commissionRatePercent; + } + + /** + * Get the status badge for this subscription's plan + */ + get statusBadge(): string { + return this.props.plan.statusBadge; + } + + /** + * Reconstitute from persistence (supports legacy plan names) + */ static fromPersistence(props: { id: string; organizationId: string; - plan: SubscriptionPlanType; + plan: string; // Accepts both old and new plan names status: SubscriptionStatusType; stripeCustomerId: string | null; stripeSubscriptionId: string | null; @@ -84,7 +112,7 @@ export class Subscription { return new Subscription({ id: props.id, organizationId: props.organizationId, - plan: SubscriptionPlan.create(props.plan), + plan: SubscriptionPlan.fromString(props.plan), status: SubscriptionStatus.create(props.status), stripeCustomerId: props.stripeCustomerId, stripeSubscriptionId: props.stripeSubscriptionId, @@ -236,7 +264,7 @@ export class Subscription { this.props.plan.value, newPlan.value, currentUserCount, - newPlan.maxLicenses, + newPlan.maxLicenses ); } diff --git a/apps/backend/src/domain/exceptions/shipment-limit-exceeded.exception.ts b/apps/backend/src/domain/exceptions/shipment-limit-exceeded.exception.ts new file mode 100644 index 0000000..ee75eec --- /dev/null +++ b/apps/backend/src/domain/exceptions/shipment-limit-exceeded.exception.ts @@ -0,0 +1,17 @@ +/** + * Shipment Limit Exceeded Exception + * + * Thrown when an organization has reached its annual shipment limit (Bronze plan). + */ +export class ShipmentLimitExceededException extends Error { + constructor( + public readonly organizationId: string, + public readonly currentCount: number, + public readonly maxCount: number + ) { + super( + `L'organisation a atteint sa limite de ${maxCount} expéditions par an (${currentCount}/${maxCount}). Passez à un plan supérieur pour des expéditions illimitées.` + ); + this.name = 'ShipmentLimitExceededException'; + } +} diff --git a/apps/backend/src/domain/exceptions/subscription.exceptions.ts b/apps/backend/src/domain/exceptions/subscription.exceptions.ts index 55cdcbd..815aa78 100644 --- a/apps/backend/src/domain/exceptions/subscription.exceptions.ts +++ b/apps/backend/src/domain/exceptions/subscription.exceptions.ts @@ -6,11 +6,11 @@ export class NoLicensesAvailableException extends Error { constructor( public readonly organizationId: string, public readonly currentLicenses: number, - public readonly maxLicenses: number, + public readonly maxLicenses: number ) { super( `No licenses available for organization ${organizationId}. ` + - `Currently using ${currentLicenses}/${maxLicenses} licenses.`, + `Currently using ${currentLicenses}/${maxLicenses} licenses.` ); this.name = 'NoLicensesAvailableException'; Object.setPrototypeOf(this, NoLicensesAvailableException.prototype); @@ -46,11 +46,11 @@ export class InvalidSubscriptionDowngradeException extends Error { public readonly currentPlan: string, public readonly targetPlan: string, public readonly currentUsers: number, - public readonly targetMaxLicenses: number, + public readonly targetMaxLicenses: number ) { super( `Cannot downgrade from ${currentPlan} to ${targetPlan}. ` + - `Current users (${currentUsers}) exceed target plan limit (${targetMaxLicenses}).`, + `Current users (${currentUsers}) exceed target plan limit (${targetMaxLicenses}).` ); this.name = 'InvalidSubscriptionDowngradeException'; Object.setPrototypeOf(this, InvalidSubscriptionDowngradeException.prototype); @@ -60,11 +60,9 @@ export class InvalidSubscriptionDowngradeException extends Error { export class SubscriptionNotActiveException extends Error { constructor( public readonly subscriptionId: string, - public readonly currentStatus: string, + public readonly currentStatus: string ) { - super( - `Subscription ${subscriptionId} is not active. Current status: ${currentStatus}`, - ); + super(`Subscription ${subscriptionId} is not active. Current status: ${currentStatus}`); this.name = 'SubscriptionNotActiveException'; Object.setPrototypeOf(this, SubscriptionNotActiveException.prototype); } @@ -73,13 +71,10 @@ export class SubscriptionNotActiveException extends Error { export class InvalidSubscriptionStatusTransitionException extends Error { constructor( public readonly fromStatus: string, - public readonly toStatus: string, + public readonly toStatus: string ) { super(`Invalid subscription status transition from ${fromStatus} to ${toStatus}`); this.name = 'InvalidSubscriptionStatusTransitionException'; - Object.setPrototypeOf( - this, - InvalidSubscriptionStatusTransitionException.prototype, - ); + Object.setPrototypeOf(this, InvalidSubscriptionStatusTransitionException.prototype); } } diff --git a/apps/backend/src/domain/ports/out/shipment-counter.port.ts b/apps/backend/src/domain/ports/out/shipment-counter.port.ts new file mode 100644 index 0000000..0aaad05 --- /dev/null +++ b/apps/backend/src/domain/ports/out/shipment-counter.port.ts @@ -0,0 +1,15 @@ +/** + * Shipment Counter Port + * + * Counts total shipments (bookings + CSV bookings) for an organization + * within a given year. Used to enforce the Bronze plan's 12 shipments/year limit. + */ + +export const SHIPMENT_COUNTER_PORT = 'SHIPMENT_COUNTER_PORT'; + +export interface ShipmentCounterPort { + /** + * Count all shipments (bookings + CSV bookings) created by an organization in a given year. + */ + countShipmentsForOrganizationInYear(organizationId: string, year: number): Promise; +} diff --git a/apps/backend/src/domain/ports/out/siret-verification.port.ts b/apps/backend/src/domain/ports/out/siret-verification.port.ts new file mode 100644 index 0000000..6cae4ca --- /dev/null +++ b/apps/backend/src/domain/ports/out/siret-verification.port.ts @@ -0,0 +1,11 @@ +export const SIRET_VERIFICATION_PORT = 'SIRET_VERIFICATION_PORT'; + +export interface SiretVerificationResult { + valid: boolean; + companyName?: string; + address?: string; +} + +export interface SiretVerificationPort { + verify(siret: string): Promise; +} diff --git a/apps/backend/src/domain/ports/out/stripe.port.ts b/apps/backend/src/domain/ports/out/stripe.port.ts index 564dbfa..0546b6c 100644 --- a/apps/backend/src/domain/ports/out/stripe.port.ts +++ b/apps/backend/src/domain/ports/out/stripe.port.ts @@ -43,6 +43,22 @@ export interface StripeSubscriptionData { cancelAtPeriodEnd: boolean; } +export interface CreateCommissionCheckoutInput { + bookingId: string; + amountCents: number; + currency: 'eur'; + customerEmail: string; + organizationId: string; + bookingDescription: string; + successUrl: string; + cancelUrl: string; +} + +export interface CreateCommissionCheckoutOutput { + sessionId: string; + sessionUrl: string; +} + export interface StripeCheckoutSessionData { sessionId: string; customerId: string | null; @@ -62,16 +78,19 @@ export interface StripePort { /** * Create a Stripe Checkout session for subscription purchase */ - createCheckoutSession( - input: CreateCheckoutSessionInput, - ): Promise; + createCheckoutSession(input: CreateCheckoutSessionInput): Promise; + + /** + * Create a Stripe Checkout session for one-time commission payment + */ + createCommissionCheckout( + input: CreateCommissionCheckoutInput + ): Promise; /** * Create a Stripe Customer Portal session for subscription management */ - createPortalSession( - input: CreatePortalSessionInput, - ): Promise; + createPortalSession(input: CreatePortalSessionInput): Promise; /** * Retrieve subscription details from Stripe @@ -101,10 +120,7 @@ export interface StripePort { /** * Verify and parse a Stripe webhook event */ - constructWebhookEvent( - payload: string | Buffer, - signature: string, - ): Promise; + constructWebhookEvent(payload: string | Buffer, signature: string): Promise; /** * Map a Stripe price ID to a subscription plan diff --git a/apps/backend/src/domain/value-objects/plan-feature.vo.ts b/apps/backend/src/domain/value-objects/plan-feature.vo.ts new file mode 100644 index 0000000..ee6bd91 --- /dev/null +++ b/apps/backend/src/domain/value-objects/plan-feature.vo.ts @@ -0,0 +1,53 @@ +/** + * Plan Feature Value Object + * + * Defines the features available per subscription plan. + * Used by the FeatureFlagGuard to enforce access control. + */ + +export type PlanFeature = + | 'dashboard' + | 'wiki' + | 'user_management' + | 'csv_export' + | 'api_access' + | 'custom_interface' + | 'dedicated_kam'; + +export const ALL_PLAN_FEATURES: readonly PlanFeature[] = [ + 'dashboard', + 'wiki', + 'user_management', + 'csv_export', + 'api_access', + 'custom_interface', + 'dedicated_kam', +]; + +export type SubscriptionPlanTypeForFeatures = 'BRONZE' | 'SILVER' | 'GOLD' | 'PLATINIUM'; + +export const PLAN_FEATURES: Record = { + BRONZE: [], + SILVER: ['dashboard', 'wiki', 'user_management', 'csv_export'], + GOLD: ['dashboard', 'wiki', 'user_management', 'csv_export', 'api_access'], + PLATINIUM: [ + 'dashboard', + 'wiki', + 'user_management', + 'csv_export', + 'api_access', + 'custom_interface', + 'dedicated_kam', + ], +}; + +export function planHasFeature( + plan: SubscriptionPlanTypeForFeatures, + feature: PlanFeature +): boolean { + return PLAN_FEATURES[plan].includes(feature); +} + +export function planGetFeatures(plan: SubscriptionPlanTypeForFeatures): readonly PlanFeature[] { + return PLAN_FEATURES[plan]; +} diff --git a/apps/backend/src/domain/value-objects/subscription-plan.vo.ts b/apps/backend/src/domain/value-objects/subscription-plan.vo.ts index b82192a..f198956 100644 --- a/apps/backend/src/domain/value-objects/subscription-plan.vo.ts +++ b/apps/backend/src/domain/value-objects/subscription-plan.vo.ts @@ -2,68 +2,109 @@ * Subscription Plan Value Object * * Represents the different subscription plans available for organizations. - * Each plan has a maximum number of licenses that determine how many users - * can be active in an organization. + * Each plan has a maximum number of licenses, shipment limits, commission rates, + * feature flags, and support levels. + * + * Plans: BRONZE (free), SILVER (249EUR/mo), GOLD (899EUR/mo), PLATINIUM (custom) */ -export type SubscriptionPlanType = 'FREE' | 'STARTER' | 'PRO' | 'ENTERPRISE'; +import { PlanFeature, PLAN_FEATURES } from './plan-feature.vo'; + +export type SubscriptionPlanType = 'BRONZE' | 'SILVER' | 'GOLD' | 'PLATINIUM'; + +export type SupportLevel = 'none' | 'email' | 'direct' | 'dedicated_kam'; +export type StatusBadge = 'none' | 'silver' | 'gold' | 'platinium'; + +/** + * Legacy plan name mapping for backward compatibility during migration. + */ +const LEGACY_PLAN_MAPPING: Record = { + FREE: 'BRONZE', + STARTER: 'SILVER', + PRO: 'GOLD', + ENTERPRISE: 'PLATINIUM', +}; interface PlanDetails { readonly name: string; readonly maxLicenses: number; // -1 means unlimited readonly monthlyPriceEur: number; readonly yearlyPriceEur: number; - readonly features: readonly string[]; + readonly maxShipmentsPerYear: number; // -1 means unlimited + readonly commissionRatePercent: number; + readonly statusBadge: StatusBadge; + readonly supportLevel: SupportLevel; + readonly planFeatures: readonly PlanFeature[]; + readonly features: readonly string[]; // Human-readable feature descriptions } const PLAN_DETAILS: Record = { - FREE: { - name: 'Free', - maxLicenses: 2, + BRONZE: { + name: 'Bronze', + maxLicenses: 1, monthlyPriceEur: 0, yearlyPriceEur: 0, - features: [ - 'Up to 2 users', - 'Basic rate search', - 'Email support', - ], + maxShipmentsPerYear: 12, + commissionRatePercent: 5, + statusBadge: 'none', + supportLevel: 'none', + planFeatures: PLAN_FEATURES.BRONZE, + features: ['1 utilisateur', '12 expéditions par an', 'Recherche de tarifs basique'], }, - STARTER: { - name: 'Starter', + SILVER: { + name: 'Silver', maxLicenses: 5, - monthlyPriceEur: 49, - yearlyPriceEur: 470, // ~20% discount + monthlyPriceEur: 249, + yearlyPriceEur: 2739, // 249 * 11 months + maxShipmentsPerYear: -1, + commissionRatePercent: 3, + statusBadge: 'silver', + supportLevel: 'email', + planFeatures: PLAN_FEATURES.SILVER, features: [ - 'Up to 5 users', - 'Advanced rate search', - 'CSV imports', - 'Priority email support', + "Jusqu'à 5 utilisateurs", + 'Expéditions illimitées', + 'Tableau de bord', + 'Wiki Maritime', + 'Gestion des utilisateurs', + 'Import CSV', + 'Support par email', ], }, - PRO: { - name: 'Pro', + GOLD: { + name: 'Gold', maxLicenses: 20, - monthlyPriceEur: 149, - yearlyPriceEur: 1430, // ~20% discount + monthlyPriceEur: 899, + yearlyPriceEur: 9889, // 899 * 11 months + maxShipmentsPerYear: -1, + commissionRatePercent: 2, + statusBadge: 'gold', + supportLevel: 'direct', + planFeatures: PLAN_FEATURES.GOLD, features: [ - 'Up to 20 users', - 'All Starter features', - 'API access', - 'Custom integrations', - 'Phone support', + "Jusqu'à 20 utilisateurs", + 'Expéditions illimitées', + 'Toutes les fonctionnalités Silver', + 'Intégration API', + 'Assistance commerciale directe', ], }, - ENTERPRISE: { - name: 'Enterprise', + PLATINIUM: { + name: 'Platinium', maxLicenses: -1, // unlimited monthlyPriceEur: 0, // custom pricing yearlyPriceEur: 0, // custom pricing + maxShipmentsPerYear: -1, + commissionRatePercent: 1, + statusBadge: 'platinium', + supportLevel: 'dedicated_kam', + planFeatures: PLAN_FEATURES.PLATINIUM, features: [ - 'Unlimited users', - 'All Pro features', - 'Dedicated account manager', - 'Custom SLA', - 'On-premise deployment option', + 'Utilisateurs illimités', + 'Toutes les fonctionnalités Gold', + 'Key Account Manager dédié', + 'Interface personnalisable', + 'Contrats tarifaires cadre', ], }, }; @@ -78,36 +119,68 @@ export class SubscriptionPlan { return new SubscriptionPlan(plan); } + /** + * Create from string with legacy name support. + * Accepts both old (FREE/STARTER/PRO/ENTERPRISE) and new (BRONZE/SILVER/GOLD/PLATINIUM) names. + */ static fromString(value: string): SubscriptionPlan { - const upperValue = value.toUpperCase() as SubscriptionPlanType; - if (!PLAN_DETAILS[upperValue]) { - throw new Error(`Invalid subscription plan: ${value}`); + const upperValue = value.toUpperCase(); + + // Check legacy mapping first + const mapped = LEGACY_PLAN_MAPPING[upperValue]; + if (mapped) { + return new SubscriptionPlan(mapped); } - return new SubscriptionPlan(upperValue); + + // Try direct match + if (PLAN_DETAILS[upperValue as SubscriptionPlanType]) { + return new SubscriptionPlan(upperValue as SubscriptionPlanType); + } + + throw new Error(`Invalid subscription plan: ${value}`); } + // Named factories + static bronze(): SubscriptionPlan { + return new SubscriptionPlan('BRONZE'); + } + + static silver(): SubscriptionPlan { + return new SubscriptionPlan('SILVER'); + } + + static gold(): SubscriptionPlan { + return new SubscriptionPlan('GOLD'); + } + + static platinium(): SubscriptionPlan { + return new SubscriptionPlan('PLATINIUM'); + } + + // Legacy aliases static free(): SubscriptionPlan { - return new SubscriptionPlan('FREE'); + return SubscriptionPlan.bronze(); } static starter(): SubscriptionPlan { - return new SubscriptionPlan('STARTER'); + return SubscriptionPlan.silver(); } static pro(): SubscriptionPlan { - return new SubscriptionPlan('PRO'); + return SubscriptionPlan.gold(); } static enterprise(): SubscriptionPlan { - return new SubscriptionPlan('ENTERPRISE'); + return SubscriptionPlan.platinium(); } static getAllPlans(): SubscriptionPlan[] { - return ['FREE', 'STARTER', 'PRO', 'ENTERPRISE'].map( - (p) => new SubscriptionPlan(p as SubscriptionPlanType), + return (['BRONZE', 'SILVER', 'GOLD', 'PLATINIUM'] as SubscriptionPlanType[]).map( + p => new SubscriptionPlan(p) ); } + // Getters get value(): SubscriptionPlanType { return this.plan; } @@ -132,6 +205,33 @@ export class SubscriptionPlan { return PLAN_DETAILS[this.plan].features; } + get maxShipmentsPerYear(): number { + return PLAN_DETAILS[this.plan].maxShipmentsPerYear; + } + + get commissionRatePercent(): number { + return PLAN_DETAILS[this.plan].commissionRatePercent; + } + + get statusBadge(): StatusBadge { + return PLAN_DETAILS[this.plan].statusBadge; + } + + get supportLevel(): SupportLevel { + return PLAN_DETAILS[this.plan].supportLevel; + } + + get planFeatures(): readonly PlanFeature[] { + return PLAN_DETAILS[this.plan].planFeatures; + } + + /** + * Check if this plan includes a specific feature + */ + hasFeature(feature: PlanFeature): boolean { + return this.planFeatures.includes(feature); + } + /** * Returns true if this plan has unlimited licenses */ @@ -140,17 +240,31 @@ export class SubscriptionPlan { } /** - * Returns true if this is a paid plan + * Returns true if this plan has unlimited shipments */ - isPaid(): boolean { - return this.plan !== 'FREE'; + hasUnlimitedShipments(): boolean { + return this.maxShipmentsPerYear === -1; } /** - * Returns true if this is the free plan + * Returns true if this is a paid plan + */ + isPaid(): boolean { + return this.plan !== 'BRONZE'; + } + + /** + * Returns true if this is the free (Bronze) plan */ isFree(): boolean { - return this.plan === 'FREE'; + return this.plan === 'BRONZE'; + } + + /** + * Returns true if this plan has custom pricing (Platinium) + */ + isCustomPricing(): boolean { + return this.plan === 'PLATINIUM'; } /** @@ -165,12 +279,7 @@ export class SubscriptionPlan { * Check if upgrade to target plan is allowed */ canUpgradeTo(targetPlan: SubscriptionPlan): boolean { - const planOrder: SubscriptionPlanType[] = [ - 'FREE', - 'STARTER', - 'PRO', - 'ENTERPRISE', - ]; + const planOrder: SubscriptionPlanType[] = ['BRONZE', 'SILVER', 'GOLD', 'PLATINIUM']; const currentIndex = planOrder.indexOf(this.plan); const targetIndex = planOrder.indexOf(targetPlan.value); return targetIndex > currentIndex; @@ -180,12 +289,7 @@ export class SubscriptionPlan { * Check if downgrade to target plan is allowed given current user count */ canDowngradeTo(targetPlan: SubscriptionPlan, currentUserCount: number): boolean { - const planOrder: SubscriptionPlanType[] = [ - 'FREE', - 'STARTER', - 'PRO', - 'ENTERPRISE', - ]; + const planOrder: SubscriptionPlanType[] = ['BRONZE', 'SILVER', 'GOLD', 'PLATINIUM']; const currentIndex = planOrder.indexOf(this.plan); const targetIndex = planOrder.indexOf(targetPlan.value); diff --git a/apps/backend/src/domain/value-objects/subscription-status.vo.ts b/apps/backend/src/domain/value-objects/subscription-status.vo.ts index de87862..959d8a9 100644 --- a/apps/backend/src/domain/value-objects/subscription-status.vo.ts +++ b/apps/backend/src/domain/value-objects/subscription-status.vo.ts @@ -191,9 +191,7 @@ export class SubscriptionStatus { */ transitionTo(newStatus: SubscriptionStatus): SubscriptionStatus { if (!this.canTransitionTo(newStatus)) { - throw new Error( - `Invalid status transition from ${this.status} to ${newStatus.value}`, - ); + throw new Error(`Invalid status transition from ${this.status} to ${newStatus.value}`); } return newStatus; } diff --git a/apps/backend/src/infrastructure/email/email.adapter.ts b/apps/backend/src/infrastructure/email/email.adapter.ts index 363c713..d32ea2a 100644 --- a/apps/backend/src/infrastructure/email/email.adapter.ts +++ b/apps/backend/src/infrastructure/email/email.adapter.ts @@ -618,6 +618,8 @@ export class EmailAdapter implements EmailPort { html, }); - this.logger.log(`New documents notification sent to ${carrierEmail} for booking ${data.bookingId}`); + this.logger.log( + `New documents notification sent to ${carrierEmail} for booking ${data.bookingId}` + ); } } diff --git a/apps/backend/src/infrastructure/external/pappers-siret.adapter.ts b/apps/backend/src/infrastructure/external/pappers-siret.adapter.ts new file mode 100644 index 0000000..7de3ba0 --- /dev/null +++ b/apps/backend/src/infrastructure/external/pappers-siret.adapter.ts @@ -0,0 +1,50 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { + SiretVerificationPort, + SiretVerificationResult, +} from '@domain/ports/out/siret-verification.port'; + +@Injectable() +export class PappersSiretAdapter implements SiretVerificationPort { + private readonly logger = new Logger(PappersSiretAdapter.name); + private readonly apiKey: string; + private readonly baseUrl = 'https://api.pappers.fr/v2'; + + constructor(private readonly configService: ConfigService) { + this.apiKey = this.configService.get('PAPPERS_API_KEY', ''); + } + + async verify(siret: string): Promise { + if (!this.apiKey) { + this.logger.warn('PAPPERS_API_KEY not configured, skipping SIRET verification'); + return { valid: false }; + } + + try { + const url = `${this.baseUrl}/entreprise?api_token=${this.apiKey}&siret=${siret}`; + const response = await fetch(url); + + if (!response.ok) { + if (response.status === 404) { + return { valid: false }; + } + this.logger.error(`Pappers API error: ${response.status} ${response.statusText}`); + return { valid: false }; + } + + const data = await response.json(); + + return { + valid: true, + companyName: data.nom_entreprise || data.denomination, + address: data.siege?.adresse_ligne_1 + ? `${data.siege.adresse_ligne_1}, ${data.siege.code_postal} ${data.siege.ville}` + : undefined, + }; + } catch (error: any) { + this.logger.error(`SIRET verification failed: ${error?.message}`, error?.stack); + return { valid: false }; + } + } +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/entities/booking.orm-entity.ts b/apps/backend/src/infrastructure/persistence/typeorm/entities/booking.orm-entity.ts index d78c7d7..f3db96e 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/entities/booking.orm-entity.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/entities/booking.orm-entity.ts @@ -92,6 +92,18 @@ export class BookingOrmEntity { @Column({ name: 'special_instructions', type: 'text', nullable: true }) specialInstructions: string | null; + @Column({ name: 'commission_rate', type: 'decimal', precision: 5, scale: 2, nullable: true }) + commissionRate: number | null; + + @Column({ + name: 'commission_amount_eur', + type: 'decimal', + precision: 12, + scale: 2, + nullable: true, + }) + commissionAmountEur: number | null; + @CreateDateColumn({ name: 'created_at' }) createdAt: Date; diff --git a/apps/backend/src/infrastructure/persistence/typeorm/entities/cookie-consent.orm-entity.ts b/apps/backend/src/infrastructure/persistence/typeorm/entities/cookie-consent.orm-entity.ts index 2f8188c..0d40645 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/entities/cookie-consent.orm-entity.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/entities/cookie-consent.orm-entity.ts @@ -1,58 +1,58 @@ -/** - * Cookie Consent ORM Entity (Infrastructure Layer) - * - * TypeORM entity for cookie consent persistence - */ - -import { - Entity, - Column, - PrimaryColumn, - CreateDateColumn, - UpdateDateColumn, - Index, - ManyToOne, - JoinColumn, -} from 'typeorm'; -import { UserOrmEntity } from './user.orm-entity'; - -@Entity('cookie_consents') -@Index('idx_cookie_consents_user', ['userId']) -export class CookieConsentOrmEntity { - @PrimaryColumn('uuid') - id: string; - - @Column({ name: 'user_id', type: 'uuid', unique: true }) - userId: string; - - @ManyToOne(() => UserOrmEntity, { onDelete: 'CASCADE' }) - @JoinColumn({ name: 'user_id' }) - user: UserOrmEntity; - - @Column({ type: 'boolean', default: true }) - essential: boolean; - - @Column({ type: 'boolean', default: false }) - functional: boolean; - - @Column({ type: 'boolean', default: false }) - analytics: boolean; - - @Column({ type: 'boolean', default: false }) - marketing: boolean; - - @Column({ name: 'ip_address', type: 'varchar', length: 45, nullable: true }) - ipAddress: string | null; - - @Column({ name: 'user_agent', type: 'text', nullable: true }) - userAgent: string | null; - - @Column({ name: 'consent_date', type: 'timestamp', default: () => 'NOW()' }) - consentDate: Date; - - @CreateDateColumn({ name: 'created_at' }) - createdAt: Date; - - @UpdateDateColumn({ name: 'updated_at' }) - updatedAt: Date; -} +/** + * Cookie Consent ORM Entity (Infrastructure Layer) + * + * TypeORM entity for cookie consent persistence + */ + +import { + Entity, + Column, + PrimaryColumn, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { UserOrmEntity } from './user.orm-entity'; + +@Entity('cookie_consents') +@Index('idx_cookie_consents_user', ['userId']) +export class CookieConsentOrmEntity { + @PrimaryColumn('uuid') + id: string; + + @Column({ name: 'user_id', type: 'uuid', unique: true }) + userId: string; + + @ManyToOne(() => UserOrmEntity, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'user_id' }) + user: UserOrmEntity; + + @Column({ type: 'boolean', default: true }) + essential: boolean; + + @Column({ type: 'boolean', default: false }) + functional: boolean; + + @Column({ type: 'boolean', default: false }) + analytics: boolean; + + @Column({ type: 'boolean', default: false }) + marketing: boolean; + + @Column({ name: 'ip_address', type: 'varchar', length: 45, nullable: true }) + ipAddress: string | null; + + @Column({ name: 'user_agent', type: 'text', nullable: true }) + userAgent: string | null; + + @Column({ name: 'consent_date', type: 'timestamp', default: () => 'NOW()' }) + consentDate: Date; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/entities/csv-booking.orm-entity.ts b/apps/backend/src/infrastructure/persistence/typeorm/entities/csv-booking.orm-entity.ts index aa1e8a4..63e0783 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/entities/csv-booking.orm-entity.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/entities/csv-booking.orm-entity.ts @@ -75,11 +75,11 @@ export class CsvBookingOrmEntity { @Column({ name: 'status', type: 'enum', - enum: ['PENDING', 'ACCEPTED', 'REJECTED', 'CANCELLED'], - default: 'PENDING', + enum: ['PENDING_PAYMENT', 'PENDING', 'ACCEPTED', 'REJECTED', 'CANCELLED'], + default: 'PENDING_PAYMENT', }) @Index() - status: 'PENDING' | 'ACCEPTED' | 'REJECTED' | 'CANCELLED'; + status: 'PENDING_PAYMENT' | 'PENDING' | 'ACCEPTED' | 'REJECTED' | 'CANCELLED'; @Column({ name: 'documents', type: 'jsonb' }) documents: Array<{ @@ -141,6 +141,21 @@ export class CsvBookingOrmEntity { @Column({ name: 'carrier_notes', type: 'text', nullable: true }) carrierNotes: string | null; + @Column({ name: 'stripe_payment_intent_id', type: 'varchar', length: 255, nullable: true }) + stripePaymentIntentId: string | null; + + @Column({ name: 'commission_rate', type: 'decimal', precision: 5, scale: 2, nullable: true }) + commissionRate: number | null; + + @Column({ + name: 'commission_amount_eur', + type: 'decimal', + precision: 12, + scale: 2, + nullable: true, + }) + commissionAmountEur: number | null; + @CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone' }) createdAt: Date; diff --git a/apps/backend/src/infrastructure/persistence/typeorm/entities/license.orm-entity.ts b/apps/backend/src/infrastructure/persistence/typeorm/entities/license.orm-entity.ts index afde22a..71b541d 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/entities/license.orm-entity.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/entities/license.orm-entity.ts @@ -5,14 +5,7 @@ * Represents user licenses linked to subscriptions. */ -import { - Entity, - Column, - PrimaryGeneratedColumn, - ManyToOne, - JoinColumn, - Index, -} from 'typeorm'; +import { Entity, Column, PrimaryGeneratedColumn, ManyToOne, JoinColumn, Index } from 'typeorm'; import { SubscriptionOrmEntity } from './subscription.orm-entity'; import { UserOrmEntity } from './user.orm-entity'; @@ -30,7 +23,7 @@ export class LicenseOrmEntity { @Column({ name: 'subscription_id', type: 'uuid' }) subscriptionId: string; - @ManyToOne(() => SubscriptionOrmEntity, (subscription) => subscription.licenses, { + @ManyToOne(() => SubscriptionOrmEntity, subscription => subscription.licenses, { onDelete: 'CASCADE', }) @JoinColumn({ name: 'subscription_id' }) diff --git a/apps/backend/src/infrastructure/persistence/typeorm/entities/organization.orm-entity.ts b/apps/backend/src/infrastructure/persistence/typeorm/entities/organization.orm-entity.ts index 8827fc7..9c59b49 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/entities/organization.orm-entity.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/entities/organization.orm-entity.ts @@ -56,6 +56,15 @@ export class OrganizationOrmEntity { @Column({ type: 'jsonb', default: '[]' }) documents: any[]; + @Column({ type: 'varchar', length: 14, nullable: true }) + siret: string | null; + + @Column({ name: 'siret_verified', type: 'boolean', default: false }) + siretVerified: boolean; + + @Column({ name: 'status_badge', type: 'varchar', length: 20, default: 'none' }) + statusBadge: string; + @Column({ name: 'is_carrier', type: 'boolean', default: false }) isCarrier: boolean; diff --git a/apps/backend/src/infrastructure/persistence/typeorm/entities/subscription.orm-entity.ts b/apps/backend/src/infrastructure/persistence/typeorm/entities/subscription.orm-entity.ts index 941b744..58b3977 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/entities/subscription.orm-entity.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/entities/subscription.orm-entity.ts @@ -19,7 +19,7 @@ import { import { OrganizationOrmEntity } from './organization.orm-entity'; import { LicenseOrmEntity } from './license.orm-entity'; -export type SubscriptionPlanOrmType = 'FREE' | 'STARTER' | 'PRO' | 'ENTERPRISE'; +export type SubscriptionPlanOrmType = 'BRONZE' | 'SILVER' | 'GOLD' | 'PLATINIUM'; export type SubscriptionStatusOrmType = | 'ACTIVE' @@ -51,8 +51,8 @@ export class SubscriptionOrmEntity { // Plan information @Column({ type: 'enum', - enum: ['FREE', 'STARTER', 'PRO', 'ENTERPRISE'], - default: 'FREE', + enum: ['BRONZE', 'SILVER', 'GOLD', 'PLATINIUM'], + default: 'BRONZE', }) plan: SubscriptionPlanOrmType; @@ -103,6 +103,6 @@ export class SubscriptionOrmEntity { updatedAt: Date; // Relations - @OneToMany(() => LicenseOrmEntity, (license) => license.subscription) + @OneToMany(() => LicenseOrmEntity, license => license.subscription) licenses: LicenseOrmEntity[]; } diff --git a/apps/backend/src/infrastructure/persistence/typeorm/mappers/booking-orm.mapper.ts b/apps/backend/src/infrastructure/persistence/typeorm/mappers/booking-orm.mapper.ts index 5a36902..df15aec 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/mappers/booking-orm.mapper.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/mappers/booking-orm.mapper.ts @@ -27,6 +27,8 @@ export class BookingOrmMapper { orm.consignee = this.partyToJson(domain.consignee); orm.cargoDescription = domain.cargoDescription; orm.specialInstructions = domain.specialInstructions || null; + orm.commissionRate = domain.commissionRate ?? null; + orm.commissionAmountEur = domain.commissionAmountEur ?? null; orm.createdAt = domain.createdAt; orm.updatedAt = domain.updatedAt; @@ -52,6 +54,9 @@ export class BookingOrmMapper { cargoDescription: orm.cargoDescription, containers: orm.containers ? orm.containers.map(c => this.ormToContainer(c)) : [], specialInstructions: orm.specialInstructions || undefined, + commissionRate: orm.commissionRate != null ? Number(orm.commissionRate) : undefined, + commissionAmountEur: + orm.commissionAmountEur != null ? Number(orm.commissionAmountEur) : undefined, createdAt: orm.createdAt, updatedAt: orm.updatedAt, }; diff --git a/apps/backend/src/infrastructure/persistence/typeorm/mappers/csv-booking.mapper.ts b/apps/backend/src/infrastructure/persistence/typeorm/mappers/csv-booking.mapper.ts index 4fee923..85217ed 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/mappers/csv-booking.mapper.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/mappers/csv-booking.mapper.ts @@ -42,7 +42,10 @@ export class CsvBookingMapper { ormEntity.respondedAt, ormEntity.notes, ormEntity.rejectionReason, - ormEntity.bookingNumber ?? undefined + ormEntity.bookingNumber ?? undefined, + ormEntity.commissionRate != null ? Number(ormEntity.commissionRate) : undefined, + ormEntity.commissionAmountEur != null ? Number(ormEntity.commissionAmountEur) : undefined, + ormEntity.stripePaymentIntentId ?? undefined ); } @@ -66,13 +69,16 @@ export class CsvBookingMapper { primaryCurrency: domain.primaryCurrency, transitDays: domain.transitDays, containerType: domain.containerType, - status: domain.status as 'PENDING' | 'ACCEPTED' | 'REJECTED' | 'CANCELLED', + status: domain.status as CsvBookingOrmEntity['status'], documents: domain.documents as any, confirmationToken: domain.confirmationToken, requestedAt: domain.requestedAt, respondedAt: domain.respondedAt, notes: domain.notes, rejectionReason: domain.rejectionReason, + stripePaymentIntentId: domain.stripePaymentIntentId ?? null, + commissionRate: domain.commissionRate ?? null, + commissionAmountEur: domain.commissionAmountEur ?? null, }; } @@ -81,10 +87,13 @@ export class CsvBookingMapper { */ static toOrmUpdate(domain: CsvBooking): Partial { return { - status: domain.status as 'PENDING' | 'ACCEPTED' | 'REJECTED' | 'CANCELLED', + status: domain.status as CsvBookingOrmEntity['status'], respondedAt: domain.respondedAt, notes: domain.notes, rejectionReason: domain.rejectionReason, + stripePaymentIntentId: domain.stripePaymentIntentId ?? null, + commissionRate: domain.commissionRate ?? null, + commissionAmountEur: domain.commissionAmountEur ?? null, }; } diff --git a/apps/backend/src/infrastructure/persistence/typeorm/mappers/license-orm.mapper.ts b/apps/backend/src/infrastructure/persistence/typeorm/mappers/license-orm.mapper.ts index 9a4ceb5..b68d699 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/mappers/license-orm.mapper.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/mappers/license-orm.mapper.ts @@ -43,6 +43,6 @@ export class LicenseOrmMapper { * Map array of ORM entities to domain entities */ static toDomainMany(orms: LicenseOrmEntity[]): License[] { - return orms.map((orm) => this.toDomain(orm)); + return orms.map(orm => this.toDomain(orm)); } } diff --git a/apps/backend/src/infrastructure/persistence/typeorm/mappers/organization-orm.mapper.ts b/apps/backend/src/infrastructure/persistence/typeorm/mappers/organization-orm.mapper.ts index 78f6660..9eb59c6 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/mappers/organization-orm.mapper.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/mappers/organization-orm.mapper.ts @@ -30,6 +30,9 @@ export class OrganizationOrmMapper { orm.addressCountry = props.address.country; orm.logoUrl = props.logoUrl || null; orm.documents = props.documents; + orm.siret = props.siret || null; + orm.siretVerified = props.siretVerified; + orm.statusBadge = props.statusBadge; orm.isActive = props.isActive; orm.createdAt = props.createdAt; orm.updatedAt = props.updatedAt; @@ -59,6 +62,9 @@ export class OrganizationOrmMapper { }, logoUrl: orm.logoUrl || undefined, documents: orm.documents || [], + siret: orm.siret || undefined, + siretVerified: orm.siretVerified ?? false, + statusBadge: (orm.statusBadge as 'none' | 'silver' | 'gold' | 'platinium') || 'none', isActive: orm.isActive, createdAt: orm.createdAt, updatedAt: orm.updatedAt, diff --git a/apps/backend/src/infrastructure/persistence/typeorm/mappers/subscription-orm.mapper.ts b/apps/backend/src/infrastructure/persistence/typeorm/mappers/subscription-orm.mapper.ts index 95c65d0..1e07da1 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/mappers/subscription-orm.mapper.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/mappers/subscription-orm.mapper.ts @@ -53,6 +53,6 @@ export class SubscriptionOrmMapper { * Map array of ORM entities to domain entities */ static toDomainMany(orms: SubscriptionOrmEntity[]): Subscription[] { - return orms.map((orm) => this.toDomain(orm)); + return orms.map(orm => this.toDomain(orm)); } } diff --git a/apps/backend/src/infrastructure/persistence/typeorm/migrations/1738100000000-CreateCookieConsent.ts b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1738100000000-CreateCookieConsent.ts index 561df7c..ccb0813 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/migrations/1738100000000-CreateCookieConsent.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1738100000000-CreateCookieConsent.ts @@ -1,62 +1,62 @@ -/** - * Migration: Create Cookie Consents Table - * GDPR compliant cookie preference storage - */ - -import { MigrationInterface, QueryRunner } from 'typeorm'; - -export class CreateCookieConsent1738100000000 implements MigrationInterface { - name = 'CreateCookieConsent1738100000000'; - - public async up(queryRunner: QueryRunner): Promise { - // Create cookie_consents table - await queryRunner.query(` - CREATE TABLE "cookie_consents" ( - "id" UUID NOT NULL DEFAULT uuid_generate_v4(), - "user_id" UUID NOT NULL, - "essential" BOOLEAN NOT NULL DEFAULT TRUE, - "functional" BOOLEAN NOT NULL DEFAULT FALSE, - "analytics" BOOLEAN NOT NULL DEFAULT FALSE, - "marketing" BOOLEAN NOT NULL DEFAULT FALSE, - "ip_address" VARCHAR(45) NULL, - "user_agent" TEXT NULL, - "consent_date" TIMESTAMP NOT NULL DEFAULT NOW(), - "created_at" TIMESTAMP NOT NULL DEFAULT NOW(), - "updated_at" TIMESTAMP NOT NULL DEFAULT NOW(), - CONSTRAINT "pk_cookie_consents" PRIMARY KEY ("id"), - CONSTRAINT "uq_cookie_consents_user" UNIQUE ("user_id"), - CONSTRAINT "fk_cookie_consents_user" FOREIGN KEY ("user_id") - REFERENCES "users"("id") ON DELETE CASCADE - ) - `); - - // Create index for fast user lookups - await queryRunner.query(` - CREATE INDEX "idx_cookie_consents_user" ON "cookie_consents" ("user_id") - `); - - // Add comments - await queryRunner.query(` - COMMENT ON TABLE "cookie_consents" IS 'GDPR compliant cookie consent preferences per user' - `); - await queryRunner.query(` - COMMENT ON COLUMN "cookie_consents"."essential" IS 'Essential cookies - always true, required for functionality' - `); - await queryRunner.query(` - COMMENT ON COLUMN "cookie_consents"."functional" IS 'Functional cookies - preferences, language, etc.' - `); - await queryRunner.query(` - COMMENT ON COLUMN "cookie_consents"."analytics" IS 'Analytics cookies - Google Analytics, Sentry, etc.' - `); - await queryRunner.query(` - COMMENT ON COLUMN "cookie_consents"."marketing" IS 'Marketing cookies - ads, tracking, remarketing' - `); - await queryRunner.query(` - COMMENT ON COLUMN "cookie_consents"."ip_address" IS 'IP address at time of consent for GDPR audit trail' - `); - } - - public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`DROP TABLE "cookie_consents"`); - } -} +/** + * Migration: Create Cookie Consents Table + * GDPR compliant cookie preference storage + */ + +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateCookieConsent1738100000000 implements MigrationInterface { + name = 'CreateCookieConsent1738100000000'; + + public async up(queryRunner: QueryRunner): Promise { + // Create cookie_consents table + await queryRunner.query(` + CREATE TABLE "cookie_consents" ( + "id" UUID NOT NULL DEFAULT uuid_generate_v4(), + "user_id" UUID NOT NULL, + "essential" BOOLEAN NOT NULL DEFAULT TRUE, + "functional" BOOLEAN NOT NULL DEFAULT FALSE, + "analytics" BOOLEAN NOT NULL DEFAULT FALSE, + "marketing" BOOLEAN NOT NULL DEFAULT FALSE, + "ip_address" VARCHAR(45) NULL, + "user_agent" TEXT NULL, + "consent_date" TIMESTAMP NOT NULL DEFAULT NOW(), + "created_at" TIMESTAMP NOT NULL DEFAULT NOW(), + "updated_at" TIMESTAMP NOT NULL DEFAULT NOW(), + CONSTRAINT "pk_cookie_consents" PRIMARY KEY ("id"), + CONSTRAINT "uq_cookie_consents_user" UNIQUE ("user_id"), + CONSTRAINT "fk_cookie_consents_user" FOREIGN KEY ("user_id") + REFERENCES "users"("id") ON DELETE CASCADE + ) + `); + + // Create index for fast user lookups + await queryRunner.query(` + CREATE INDEX "idx_cookie_consents_user" ON "cookie_consents" ("user_id") + `); + + // Add comments + await queryRunner.query(` + COMMENT ON TABLE "cookie_consents" IS 'GDPR compliant cookie consent preferences per user' + `); + await queryRunner.query(` + COMMENT ON COLUMN "cookie_consents"."essential" IS 'Essential cookies - always true, required for functionality' + `); + await queryRunner.query(` + COMMENT ON COLUMN "cookie_consents"."functional" IS 'Functional cookies - preferences, language, etc.' + `); + await queryRunner.query(` + COMMENT ON COLUMN "cookie_consents"."analytics" IS 'Analytics cookies - Google Analytics, Sentry, etc.' + `); + await queryRunner.query(` + COMMENT ON COLUMN "cookie_consents"."marketing" IS 'Marketing cookies - ads, tracking, remarketing' + `); + await queryRunner.query(` + COMMENT ON COLUMN "cookie_consents"."ip_address" IS 'IP address at time of consent for GDPR audit trail' + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "cookie_consents"`); + } +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/migrations/1740000000001-RenamePlansToBronzeSilverGoldPlatinium.ts b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1740000000001-RenamePlansToBronzeSilverGoldPlatinium.ts new file mode 100644 index 0000000..c7bdb41 --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1740000000001-RenamePlansToBronzeSilverGoldPlatinium.ts @@ -0,0 +1,92 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Rename subscription plans: + * FREE -> BRONZE, STARTER -> SILVER, PRO -> GOLD, ENTERPRISE -> PLATINIUM + * + * PostgreSQL does not support removing values from an enum type directly, + * so we create a new enum, migrate the column, and drop the old one. + */ +export class RenamePlansToBronzeSilverGoldPlatinium1740000000001 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // Step 1: Create new enum type + await queryRunner.query( + `CREATE TYPE "subscription_plan_enum_new" AS ENUM ('BRONZE', 'SILVER', 'GOLD', 'PLATINIUM')` + ); + + // Step 2: Convert the column to VARCHAR temporarily so we can update values + await queryRunner.query(`ALTER TABLE "subscriptions" ALTER COLUMN "plan" TYPE VARCHAR(20)`); + + // Step 3: Update existing values + await queryRunner.query(`UPDATE "subscriptions" SET "plan" = 'BRONZE' WHERE "plan" = 'FREE'`); + await queryRunner.query( + `UPDATE "subscriptions" SET "plan" = 'SILVER' WHERE "plan" = 'STARTER'` + ); + await queryRunner.query(`UPDATE "subscriptions" SET "plan" = 'GOLD' WHERE "plan" = 'PRO'`); + await queryRunner.query( + `UPDATE "subscriptions" SET "plan" = 'PLATINIUM' WHERE "plan" = 'ENTERPRISE'` + ); + + // Step 4: Drop existing default (required before changing enum type) + await queryRunner.query(`ALTER TABLE "subscriptions" ALTER COLUMN "plan" DROP DEFAULT`); + + // Step 5: Set column to new enum type + await queryRunner.query( + `ALTER TABLE "subscriptions" ALTER COLUMN "plan" TYPE "subscription_plan_enum_new" USING "plan"::"subscription_plan_enum_new"` + ); + + // Step 6: Set new default + await queryRunner.query(`ALTER TABLE "subscriptions" ALTER COLUMN "plan" SET DEFAULT 'BRONZE'`); + + // Step 7: Drop old enum type (name may vary — TypeORM often creates it as subscriptions_plan_enum) + // We handle both possible names + await queryRunner.query(` + DO $$ BEGIN + IF EXISTS (SELECT 1 FROM pg_type WHERE typname = 'subscriptions_plan_enum') THEN + DROP TYPE "subscriptions_plan_enum"; + END IF; + END $$; + `); + + // Step 8: Rename new enum to standard name + await queryRunner.query( + `ALTER TYPE "subscription_plan_enum_new" RENAME TO "subscriptions_plan_enum"` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + // Reverse: create old enum, migrate back + await queryRunner.query( + `CREATE TYPE "subscription_plan_enum_old" AS ENUM ('FREE', 'STARTER', 'PRO', 'ENTERPRISE')` + ); + + await queryRunner.query(`ALTER TABLE "subscriptions" ALTER COLUMN "plan" TYPE VARCHAR(20)`); + + await queryRunner.query(`UPDATE "subscriptions" SET "plan" = 'FREE' WHERE "plan" = 'BRONZE'`); + await queryRunner.query( + `UPDATE "subscriptions" SET "plan" = 'STARTER' WHERE "plan" = 'SILVER'` + ); + await queryRunner.query(`UPDATE "subscriptions" SET "plan" = 'PRO' WHERE "plan" = 'GOLD'`); + await queryRunner.query( + `UPDATE "subscriptions" SET "plan" = 'ENTERPRISE' WHERE "plan" = 'PLATINIUM'` + ); + + await queryRunner.query( + `ALTER TABLE "subscriptions" ALTER COLUMN "plan" TYPE "subscription_plan_enum_old" USING "plan"::"subscription_plan_enum_old"` + ); + + await queryRunner.query(`ALTER TABLE "subscriptions" ALTER COLUMN "plan" SET DEFAULT 'FREE'`); + + await queryRunner.query(` + DO $$ BEGIN + IF EXISTS (SELECT 1 FROM pg_type WHERE typname = 'subscriptions_plan_enum') THEN + DROP TYPE "subscriptions_plan_enum"; + END IF; + END $$; + `); + + await queryRunner.query( + `ALTER TYPE "subscription_plan_enum_old" RENAME TO "subscriptions_plan_enum"` + ); + } +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/migrations/1740000000002-AddCommissionFields.ts b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1740000000002-AddCommissionFields.ts new file mode 100644 index 0000000..204fb0e --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1740000000002-AddCommissionFields.ts @@ -0,0 +1,43 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddCommissionFields1740000000002 implements MigrationInterface { + name = 'AddCommissionFields1740000000002'; + + public async up(queryRunner: QueryRunner): Promise { + // Add commission columns to csv_bookings (bookings table may not exist yet) + await queryRunner.query(` + ALTER TABLE "csv_bookings" + ADD COLUMN IF NOT EXISTS "commission_rate" DECIMAL(5,2), + ADD COLUMN IF NOT EXISTS "commission_amount_eur" DECIMAL(12,2) + `); + + // Only alter bookings table if it exists + await queryRunner.query(` + DO $$ BEGIN + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'bookings') THEN + ALTER TABLE "bookings" + ADD COLUMN "commission_rate" DECIMAL(5,2), + ADD COLUMN "commission_amount_eur" DECIMAL(12,2); + END IF; + END $$; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "csv_bookings" + DROP COLUMN IF EXISTS "commission_amount_eur", + DROP COLUMN IF EXISTS "commission_rate" + `); + + await queryRunner.query(` + DO $$ BEGIN + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'bookings') THEN + ALTER TABLE "bookings" + DROP COLUMN "commission_amount_eur", + DROP COLUMN "commission_rate"; + END IF; + END $$; + `); + } +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/migrations/1740000000003-AddSiretAndStatusBadgeToOrganizations.ts b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1740000000003-AddSiretAndStatusBadgeToOrganizations.ts new file mode 100644 index 0000000..eabe38c --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1740000000003-AddSiretAndStatusBadgeToOrganizations.ts @@ -0,0 +1,23 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddSiretAndStatusBadgeToOrganizations1740000000003 implements MigrationInterface { + name = 'AddSiretAndStatusBadgeToOrganizations1740000000003'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "organizations" + ADD COLUMN "siret" VARCHAR(14), + ADD COLUMN "siret_verified" BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN "status_badge" VARCHAR(20) NOT NULL DEFAULT 'none' + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "organizations" + DROP COLUMN "status_badge", + DROP COLUMN "siret_verified", + DROP COLUMN "siret" + `); + } +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/migrations/1740000000004-AddPendingPaymentStatus.ts b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1740000000004-AddPendingPaymentStatus.ts new file mode 100644 index 0000000..04e6656 --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1740000000004-AddPendingPaymentStatus.ts @@ -0,0 +1,75 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Migration: Add PENDING_PAYMENT status to csv_bookings enum + stripe_payment_intent_id column + */ +export class AddPendingPaymentStatus1740000000004 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // Drop the default before changing enum type + await queryRunner.query(` + ALTER TABLE "csv_bookings" ALTER COLUMN "status" DROP DEFAULT + `); + + // Create new enum with PENDING_PAYMENT + await queryRunner.query(` + CREATE TYPE "csv_booking_status_new" AS ENUM ('PENDING_PAYMENT', 'PENDING', 'ACCEPTED', 'REJECTED', 'CANCELLED') + `); + + // Swap column to new enum type + await queryRunner.query(` + ALTER TABLE "csv_bookings" + ALTER COLUMN "status" TYPE "csv_booking_status_new" + USING "status"::text::"csv_booking_status_new" + `); + + // Drop old enum and rename new + await queryRunner.query(`DROP TYPE "csv_booking_status"`); + await queryRunner.query(`ALTER TYPE "csv_booking_status_new" RENAME TO "csv_booking_status"`); + + // Set new default + await queryRunner.query(` + ALTER TABLE "csv_bookings" ALTER COLUMN "status" SET DEFAULT 'PENDING_PAYMENT' + `); + + // Add stripe_payment_intent_id column + await queryRunner.query(` + ALTER TABLE "csv_bookings" + ADD COLUMN IF NOT EXISTS "stripe_payment_intent_id" VARCHAR(255) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + // Remove stripe_payment_intent_id column + await queryRunner.query(` + ALTER TABLE "csv_bookings" DROP COLUMN IF EXISTS "stripe_payment_intent_id" + `); + + // Update any PENDING_PAYMENT rows to PENDING + await queryRunner.query(` + UPDATE "csv_bookings" SET "status" = 'PENDING' WHERE "status" = 'PENDING_PAYMENT' + `); + + // Drop default + await queryRunner.query(` + ALTER TABLE "csv_bookings" ALTER COLUMN "status" DROP DEFAULT + `); + + // Recreate original enum without PENDING_PAYMENT + await queryRunner.query(` + CREATE TYPE "csv_booking_status_old" AS ENUM ('PENDING', 'ACCEPTED', 'REJECTED', 'CANCELLED') + `); + + await queryRunner.query(` + ALTER TABLE "csv_bookings" + ALTER COLUMN "status" TYPE "csv_booking_status_old" + USING "status"::text::"csv_booking_status_old" + `); + + await queryRunner.query(`DROP TYPE "csv_booking_status"`); + await queryRunner.query(`ALTER TYPE "csv_booking_status_old" RENAME TO "csv_booking_status"`); + + await queryRunner.query(` + ALTER TABLE "csv_bookings" ALTER COLUMN "status" SET DEFAULT 'PENDING' + `); + } +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/repositories/shipment-counter.repository.ts b/apps/backend/src/infrastructure/persistence/typeorm/repositories/shipment-counter.repository.ts new file mode 100644 index 0000000..f60e0b5 --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/repositories/shipment-counter.repository.ts @@ -0,0 +1,32 @@ +/** + * Shipment Counter Repository + * + * Counts total shipments (bookings + CSV bookings) for an organization in a year. + * Used to enforce Bronze plan's 12 shipments/year limit. + */ + +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { ShipmentCounterPort } from '@domain/ports/out/shipment-counter.port'; +import { CsvBookingOrmEntity } from '../entities/csv-booking.orm-entity'; + +@Injectable() +export class TypeOrmShipmentCounterRepository implements ShipmentCounterPort { + constructor( + @InjectRepository(CsvBookingOrmEntity) + private readonly csvBookingRepository: Repository + ) {} + + async countShipmentsForOrganizationInYear(organizationId: string, year: number): Promise { + const startOfYear = new Date(year, 0, 1); + const startOfNextYear = new Date(year + 1, 0, 1); + + return this.csvBookingRepository + .createQueryBuilder('csv_booking') + .where('csv_booking.organization_id = :organizationId', { organizationId }) + .andWhere('csv_booking.created_at >= :start', { start: startOfYear }) + .andWhere('csv_booking.created_at < :end', { end: startOfNextYear }) + .getCount(); + } +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-license.repository.ts b/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-license.repository.ts index 8c74cd6..9081e21 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-license.repository.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-license.repository.ts @@ -16,7 +16,7 @@ import { LicenseOrmMapper } from '../mappers/license-orm.mapper'; export class TypeOrmLicenseRepository implements LicenseRepository { constructor( @InjectRepository(LicenseOrmEntity) - private readonly repository: Repository, + private readonly repository: Repository ) {} async save(license: License): Promise { diff --git a/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-subscription.repository.ts b/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-subscription.repository.ts index 5469475..27ee649 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-subscription.repository.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-subscription.repository.ts @@ -16,7 +16,7 @@ import { SubscriptionOrmMapper } from '../mappers/subscription-orm.mapper'; export class TypeOrmSubscriptionRepository implements SubscriptionRepository { constructor( @InjectRepository(SubscriptionOrmEntity) - private readonly repository: Repository, + private readonly repository: Repository ) {} async save(subscription: Subscription): Promise { @@ -35,9 +35,7 @@ export class TypeOrmSubscriptionRepository implements SubscriptionRepository { return orm ? SubscriptionOrmMapper.toDomain(orm) : null; } - async findByStripeSubscriptionId( - stripeSubscriptionId: string, - ): Promise { + async findByStripeSubscriptionId(stripeSubscriptionId: string): Promise { const orm = await this.repository.findOne({ where: { stripeSubscriptionId } }); return orm ? SubscriptionOrmMapper.toDomain(orm) : null; } diff --git a/apps/backend/src/infrastructure/stripe/stripe.adapter.ts b/apps/backend/src/infrastructure/stripe/stripe.adapter.ts index cf5386a..4cd3665 100644 --- a/apps/backend/src/infrastructure/stripe/stripe.adapter.ts +++ b/apps/backend/src/infrastructure/stripe/stripe.adapter.ts @@ -11,6 +11,8 @@ import { StripePort, CreateCheckoutSessionInput, CreateCheckoutSessionOutput, + CreateCommissionCheckoutInput, + CreateCommissionCheckoutOutput, CreatePortalSessionInput, CreatePortalSessionOutput, StripeSubscriptionData, @@ -42,50 +44,46 @@ export class StripeAdapter implements StripePort { this.planPriceMap = new Map(); // Configure plan price IDs from environment - const starterMonthly = this.configService.get('STRIPE_STARTER_MONTHLY_PRICE_ID'); - const starterYearly = this.configService.get('STRIPE_STARTER_YEARLY_PRICE_ID'); - const proMonthly = this.configService.get('STRIPE_PRO_MONTHLY_PRICE_ID'); - const proYearly = this.configService.get('STRIPE_PRO_YEARLY_PRICE_ID'); - const enterpriseMonthly = this.configService.get('STRIPE_ENTERPRISE_MONTHLY_PRICE_ID'); - const enterpriseYearly = this.configService.get('STRIPE_ENTERPRISE_YEARLY_PRICE_ID'); + const silverMonthly = this.configService.get('STRIPE_SILVER_MONTHLY_PRICE_ID'); + const silverYearly = this.configService.get('STRIPE_SILVER_YEARLY_PRICE_ID'); + const goldMonthly = this.configService.get('STRIPE_GOLD_MONTHLY_PRICE_ID'); + const goldYearly = this.configService.get('STRIPE_GOLD_YEARLY_PRICE_ID'); + const platiniumMonthly = this.configService.get('STRIPE_PLATINIUM_MONTHLY_PRICE_ID'); + const platiniumYearly = this.configService.get('STRIPE_PLATINIUM_YEARLY_PRICE_ID'); - if (starterMonthly) this.priceIdMap.set(starterMonthly, 'STARTER'); - if (starterYearly) this.priceIdMap.set(starterYearly, 'STARTER'); - if (proMonthly) this.priceIdMap.set(proMonthly, 'PRO'); - if (proYearly) this.priceIdMap.set(proYearly, 'PRO'); - if (enterpriseMonthly) this.priceIdMap.set(enterpriseMonthly, 'ENTERPRISE'); - if (enterpriseYearly) this.priceIdMap.set(enterpriseYearly, 'ENTERPRISE'); + if (silverMonthly) this.priceIdMap.set(silverMonthly, 'SILVER'); + if (silverYearly) this.priceIdMap.set(silverYearly, 'SILVER'); + if (goldMonthly) this.priceIdMap.set(goldMonthly, 'GOLD'); + if (goldYearly) this.priceIdMap.set(goldYearly, 'GOLD'); + if (platiniumMonthly) this.priceIdMap.set(platiniumMonthly, 'PLATINIUM'); + if (platiniumYearly) this.priceIdMap.set(platiniumYearly, 'PLATINIUM'); - this.planPriceMap.set('STARTER', { - monthly: starterMonthly || '', - yearly: starterYearly || '', + this.planPriceMap.set('SILVER', { + monthly: silverMonthly || '', + yearly: silverYearly || '', }); - this.planPriceMap.set('PRO', { - monthly: proMonthly || '', - yearly: proYearly || '', + this.planPriceMap.set('GOLD', { + monthly: goldMonthly || '', + yearly: goldYearly || '', }); - this.planPriceMap.set('ENTERPRISE', { - monthly: enterpriseMonthly || '', - yearly: enterpriseYearly || '', + this.planPriceMap.set('PLATINIUM', { + monthly: platiniumMonthly || '', + yearly: platiniumYearly || '', }); } async createCheckoutSession( - input: CreateCheckoutSessionInput, + input: CreateCheckoutSessionInput ): Promise { const planPrices = this.planPriceMap.get(input.plan); if (!planPrices) { throw new Error(`No price configuration for plan: ${input.plan}`); } - const priceId = input.billingInterval === 'yearly' - ? planPrices.yearly - : planPrices.monthly; + const priceId = input.billingInterval === 'yearly' ? planPrices.yearly : planPrices.monthly; if (!priceId) { - throw new Error( - `No ${input.billingInterval} price configured for plan: ${input.plan}`, - ); + throw new Error(`No ${input.billingInterval} price configured for plan: ${input.plan}`); } const sessionParams: Stripe.Checkout.SessionCreateParams = { @@ -119,7 +117,7 @@ export class StripeAdapter implements StripePort { const session = await this.stripe.checkout.sessions.create(sessionParams); this.logger.log( - `Created checkout session ${session.id} for organization ${input.organizationId}`, + `Created checkout session ${session.id} for organization ${input.organizationId}` ); return { @@ -128,9 +126,46 @@ export class StripeAdapter implements StripePort { }; } - async createPortalSession( - input: CreatePortalSessionInput, - ): Promise { + async createCommissionCheckout( + input: CreateCommissionCheckoutInput + ): Promise { + const session = await this.stripe.checkout.sessions.create({ + mode: 'payment', + payment_method_types: ['card'], + line_items: [ + { + price_data: { + currency: input.currency, + unit_amount: input.amountCents, + product_data: { + name: 'Commission Xpeditis', + description: input.bookingDescription, + }, + }, + quantity: 1, + }, + ], + customer_email: input.customerEmail, + success_url: input.successUrl, + cancel_url: input.cancelUrl, + metadata: { + type: 'commission', + bookingId: input.bookingId, + organizationId: input.organizationId, + }, + }); + + this.logger.log( + `Created commission checkout session ${session.id} for booking ${input.bookingId}` + ); + + return { + sessionId: session.id, + sessionUrl: session.url || '', + }; + } + + async createPortalSession(input: CreatePortalSessionInput): Promise { const session = await this.stripe.billingPortal.sessions.create({ customer: input.customerId, return_url: input.returnUrl, @@ -211,13 +246,9 @@ export class StripeAdapter implements StripePort { async constructWebhookEvent( payload: string | Buffer, - signature: string, + signature: string ): Promise { - const event = this.stripe.webhooks.constructEvent( - payload, - signature, - this.webhookSecret, - ); + const event = this.stripe.webhooks.constructEvent(payload, signature, this.webhookSecret); return { type: event.type, diff --git a/apps/frontend/app/dashboard/booking/[id]/pay/page.tsx b/apps/frontend/app/dashboard/booking/[id]/pay/page.tsx new file mode 100644 index 0000000..f578134 --- /dev/null +++ b/apps/frontend/app/dashboard/booking/[id]/pay/page.tsx @@ -0,0 +1,286 @@ +/** + * Commission Payment Page + * + * Shows booking summary and commission amount, allows payment via Stripe or bank transfer + */ + +'use client'; + +import { useState, useEffect } from 'react'; +import { useRouter, useParams } from 'next/navigation'; +import { CreditCard, Building2, ArrowLeft, Loader2, AlertTriangle, CheckCircle } from 'lucide-react'; +import { getCsvBooking, payBookingCommission } from '@/lib/api/bookings'; + +interface BookingData { + id: string; + bookingNumber?: string; + carrierName: string; + carrierEmail: string; + origin: string; + destination: string; + volumeCBM: number; + weightKG: number; + palletCount: number; + priceEUR: number; + priceUSD: number; + primaryCurrency: string; + transitDays: number; + containerType: string; + status: string; + commissionRate?: number; + commissionAmountEur?: number; +} + +export default function PayCommissionPage() { + const router = useRouter(); + const params = useParams(); + const bookingId = params.id as string; + + const [booking, setBooking] = useState(null); + const [loading, setLoading] = useState(true); + const [paying, setPaying] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + async function fetchBooking() { + try { + const data = await getCsvBooking(bookingId); + setBooking(data as any); + + // If booking is not in PENDING_PAYMENT status, redirect + if (data.status !== 'PENDING_PAYMENT') { + router.replace('/dashboard/bookings'); + } + } catch (err) { + console.error('Failed to fetch booking:', err); + setError('Impossible de charger les details du booking'); + } finally { + setLoading(false); + } + } + + if (bookingId) { + fetchBooking(); + } + }, [bookingId, router]); + + const handlePayByCard = async () => { + setPaying(true); + setError(null); + + try { + const result = await payBookingCommission(bookingId); + // Redirect to Stripe Checkout + window.location.href = result.sessionUrl; + } catch (err) { + console.error('Payment error:', err); + setError(err instanceof Error ? err.message : 'Erreur lors de la creation du paiement'); + setPaying(false); + } + }; + + const formatPrice = (price: number, currency: string) => { + return new Intl.NumberFormat('fr-FR', { + style: 'currency', + currency, + }).format(price); + }; + + if (loading) { + return ( +
+
+ + Chargement... +
+
+ ); + } + + if (error && !booking) { + return ( +
+
+ +

{error}

+ +
+
+ ); + } + + if (!booking) return null; + + const commissionAmount = booking.commissionAmountEur || 0; + const commissionRate = booking.commissionRate || 0; + + return ( +
+
+ {/* Header */} +
+ + +
+

Paiement de la commission

+

+ Finalisez votre booking en payant la commission de service +

+
+
+ + {/* Error */} + {error && ( +
+
+ +

{error}

+
+
+ )} + + {/* Booking Summary */} +
+

Recapitulatif du booking

+ +
+ {booking.bookingNumber && ( +
+ Numero : + {booking.bookingNumber} +
+ )} +
+ Transporteur : + {booking.carrierName} +
+
+ Trajet : + + {booking.origin} → {booking.destination} + +
+
+ Volume / Poids : + + {booking.volumeCBM} CBM / {booking.weightKG} kg + +
+
+ Transit : + {booking.transitDays} jours +
+
+ Prix transport : + + {formatPrice(booking.priceEUR, 'EUR')} + +
+
+
+ + {/* Commission Details */} +
+

Commission de service

+ +
+
+
+

+ Commission ({commissionRate}% du prix transport) +

+

+ {formatPrice(booking.priceEUR, 'EUR')} x {commissionRate}% +

+
+

+ {formatPrice(commissionAmount, 'EUR')} +

+
+
+ +
+
+ +

+ Apres le paiement, votre demande sera envoyee par email au transporteur ({booking.carrierEmail}). + Vous recevrez une notification des que le transporteur repond. +

+
+
+
+ + {/* Payment Methods */} +
+ {/* Pay by Card (Stripe) */} + + + {/* Pay by Bank Transfer (informational) */} +
+
+ +

Payer par virement bancaire

+
+ +
+
+ Beneficiaire : + XPEDITIS SAS +
+
+ IBAN : + FR76 XXXX XXXX XXXX XXXX XXXX XXX +
+
+ BIC : + XXXXXXXX +
+
+ Montant : + {formatPrice(commissionAmount, 'EUR')} +
+
+ Reference : + {booking.bookingNumber || booking.id.slice(0, 8)} +
+
+ +

+ Le traitement du virement peut prendre 1 a 3 jours ouvrables. + Votre booking sera active une fois le paiement recu et verifie. +

+
+
+
+
+ ); +} diff --git a/apps/frontend/app/dashboard/booking/[id]/payment-success/page.tsx b/apps/frontend/app/dashboard/booking/[id]/payment-success/page.tsx new file mode 100644 index 0000000..0865510 --- /dev/null +++ b/apps/frontend/app/dashboard/booking/[id]/payment-success/page.tsx @@ -0,0 +1,147 @@ +/** + * Payment Success Page + * + * Displayed after successful Stripe payment. Confirms the payment and activates the booking. + */ + +'use client'; + +import { useState, useEffect, useRef } from 'react'; +import { useRouter, useParams, useSearchParams } from 'next/navigation'; +import { CheckCircle, Loader2, AlertTriangle, Mail, ArrowRight } from 'lucide-react'; +import { confirmBookingPayment } from '@/lib/api/bookings'; + +export default function PaymentSuccessPage() { + const router = useRouter(); + const params = useParams(); + const searchParams = useSearchParams(); + const bookingId = params.id as string; + const sessionId = searchParams.get('session_id'); + + const [status, setStatus] = useState<'confirming' | 'success' | 'error'>('confirming'); + const [error, setError] = useState(null); + const confirmedRef = useRef(false); + + useEffect(() => { + async function confirm() { + if (!sessionId || !bookingId || confirmedRef.current) return; + confirmedRef.current = true; + + try { + await confirmBookingPayment(bookingId, sessionId); + setStatus('success'); + } catch (err) { + console.error('Payment confirmation error:', err); + setError( + err instanceof Error ? err.message : 'Erreur lors de la confirmation du paiement' + ); + setStatus('error'); + } + } + + confirm(); + }, [bookingId, sessionId]); + + if (!sessionId) { + return ( +
+
+ +

Session invalide

+

Aucune session de paiement trouvee.

+ +
+
+ ); + } + + return ( +
+
+ {status === 'confirming' && ( + <> + +

Confirmation du paiement...

+

+ Veuillez patienter pendant que nous verifions votre paiement et activons votre booking. +

+ + )} + + {status === 'success' && ( + <> +
+
+ +
+
+

Paiement confirme !

+

+ Votre commission a ete payee avec succes. Un email a ete envoye au transporteur avec votre demande de booking. +

+ +
+
+ + + Email envoye au transporteur + +
+

+ Vous recevrez une notification des que le transporteur repond (sous 7 jours max) +

+
+ + + + )} + + {status === 'error' && ( + <> + +

Erreur de confirmation

+

{error}

+

+ Si votre paiement a ete debite, contactez le support. Votre booking sera active manuellement. +

+
+ + +
+ + )} +
+
+ ); +} diff --git a/apps/frontend/app/dashboard/booking/new/page.tsx b/apps/frontend/app/dashboard/booking/new/page.tsx index ebff420..8254afb 100644 --- a/apps/frontend/app/dashboard/booking/new/page.tsx +++ b/apps/frontend/app/dashboard/booking/new/page.tsx @@ -177,8 +177,8 @@ function NewBookingPageContent() { // Send to API using client function const result = await createCsvBooking(formDataToSend); - // Redirect to success page - router.push(`/dashboard/bookings?success=true&id=${result.id}`); + // Redirect to commission payment page + router.push(`/dashboard/booking/${result.id}/pay`); } catch (err) { console.error('Booking creation error:', err); setError(err instanceof Error ? err.message : 'Une erreur est survenue'); diff --git a/apps/frontend/app/dashboard/layout.tsx b/apps/frontend/app/dashboard/layout.tsx index 5bac1c5..2a45b19 100644 --- a/apps/frontend/app/dashboard/layout.tsx +++ b/apps/frontend/app/dashboard/layout.tsx @@ -22,10 +22,15 @@ import { Building2, Users, LogOut, + Lock, } from 'lucide-react'; +import { useSubscription } from '@/lib/context/subscription-context'; +import StatusBadge from '@/components/ui/StatusBadge'; +import type { PlanFeature } from '@/lib/api/subscriptions'; export default function DashboardLayout({ children }: { children: React.ReactNode }) { const { user, logout, loading, isAuthenticated } = useAuth(); + const { hasFeature, subscription } = useSubscription(); const pathname = usePathname(); const router = useRouter(); const [sidebarOpen, setSidebarOpen] = useState(false); @@ -48,16 +53,16 @@ export default function DashboardLayout({ children }: { children: React.ReactNod return null; } - const navigation = [ - { name: 'Tableau de bord', href: '/dashboard', icon: BarChart3 }, + const navigation: Array<{ name: string; href: string; icon: any; requiredFeature?: PlanFeature }> = [ + { name: 'Tableau de bord', href: '/dashboard', icon: BarChart3, requiredFeature: 'dashboard' }, { name: 'Réservations', href: '/dashboard/bookings', icon: Package }, { name: 'Documents', href: '/dashboard/documents', icon: FileText }, - { name: 'Suivi', href: '/dashboard/track-trace', icon: Search }, - { name: 'Wiki Maritime', href: '/dashboard/wiki', icon: BookOpen }, + { name: 'Suivi', href: '/dashboard/track-trace', icon: Search, requiredFeature: 'dashboard' }, + { name: 'Wiki Maritime', href: '/dashboard/wiki', icon: BookOpen, requiredFeature: 'wiki' }, { name: 'Organisation', href: '/dashboard/settings/organization', icon: Building2 }, // ADMIN and MANAGER only navigation items ...(user?.role === 'ADMIN' || user?.role === 'MANAGER' ? [ - { name: 'Utilisateurs', href: '/dashboard/settings/users', icon: Users }, + { name: 'Utilisateurs', href: '/dashboard/settings/users', icon: Users, requiredFeature: 'user_management' as PlanFeature }, ] : []), ]; @@ -114,20 +119,26 @@ export default function DashboardLayout({ children }: { children: React.ReactNod {/* Navigation */}