diff --git a/apps/backend/.env.example b/apps/backend/.env.example index 3da8e77..831f9b8 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -84,3 +84,18 @@ RATE_LIMIT_MAX=100 # Monitoring SENTRY_DSN=your-sentry-dsn + +# Frontend URL (for redirects) +FRONTEND_URL=http://localhost:3000 + +# Stripe (Subscriptions & Payments) +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 diff --git a/apps/backend/package-lock.json b/apps/backend/package-lock.json index 914fd4b..ec5c11b 100644 --- a/apps/backend/package-lock.json +++ b/apps/backend/package-lock.json @@ -59,7 +59,9 @@ "reflect-metadata": "^0.1.14", "rxjs": "^7.8.1", "socket.io": "^4.8.1", - "typeorm": "^0.3.17" + "stripe": "^14.14.0", + "typeorm": "^0.3.17", + "uuid": "^9.0.1" }, "devDependencies": { "@faker-js/faker": "^10.0.0", @@ -14570,6 +14572,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/stripe": { + "version": "14.25.0", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-14.25.0.tgz", + "integrity": "sha512-wQS3GNMofCXwH8TSje8E1SE8zr6ODiGtHQgPtO95p9Mb4FhKC9jvXR2NUTpZ9ZINlckJcFidCmaTFV4P6vsb9g==", + "license": "MIT", + "dependencies": { + "@types/node": ">=8.1.0", + "qs": "^6.11.0" + }, + "engines": { + "node": ">=12.*" + } + }, "node_modules/strnum": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.1.tgz", diff --git a/apps/backend/package.json b/apps/backend/package.json index c5763d4..82ec0e6 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -75,7 +75,9 @@ "reflect-metadata": "^0.1.14", "rxjs": "^7.8.1", "socket.io": "^4.8.1", - "typeorm": "^0.3.17" + "stripe": "^14.14.0", + "typeorm": "^0.3.17", + "uuid": "^9.0.1" }, "devDependencies": { "@faker-js/faker": "^10.0.0", diff --git a/apps/backend/scripts/list-stripe-prices.js b/apps/backend/scripts/list-stripe-prices.js new file mode 100644 index 0000000..2756851 --- /dev/null +++ b/apps/backend/scripts/list-stripe-prices.js @@ -0,0 +1,55 @@ +/** + * Script to list all Stripe prices + * Run with: node scripts/list-stripe-prices.js + */ + +const Stripe = require('stripe'); + +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || 'sk_test_51R8p8R4atifoBlu1U9sMJh3rkQbO1G1xeguwFMQYMIMeaLNrTX7YFO5Ovu3P1VfbwcOoEmiy6I0UWi4DThNNzHG100YF75TnJr'); + +async function listPrices() { + console.log('Fetching Stripe prices...\n'); + + try { + const prices = await stripe.prices.list({ limit: 50, expand: ['data.product'] }); + + if (prices.data.length === 0) { + console.log('No prices found. You need to create prices in Stripe Dashboard.'); + console.log('\nSteps:'); + console.log('1. Go to https://dashboard.stripe.com/products'); + console.log('2. Click on each product (Starter, Pro, Enterprise)'); + console.log('3. Add a recurring price (monthly and yearly)'); + console.log('4. Copy the Price IDs (format: price_xxxxx)'); + return; + } + + console.log('Available Prices:\n'); + console.log('='.repeat(100)); + + for (const price of prices.data) { + const product = typeof price.product === 'object' ? price.product : { name: price.product }; + const interval = price.recurring ? `${price.recurring.interval}ly` : 'one-time'; + const amount = (price.unit_amount / 100).toFixed(2); + + console.log(`Price ID: ${price.id}`); + console.log(`Product: ${product.name || product.id}`); + console.log(`Amount: ${amount} ${price.currency.toUpperCase()}`); + console.log(`Interval: ${interval}`); + console.log(`Active: ${price.active}`); + console.log('-'.repeat(100)); + } + + console.log('\n\nCopy the relevant Price IDs to your .env file:'); + console.log('STRIPE_STARTER_MONTHLY_PRICE_ID=price_xxxxx'); + console.log('STRIPE_STARTER_YEARLY_PRICE_ID=price_xxxxx'); + console.log('STRIPE_PRO_MONTHLY_PRICE_ID=price_xxxxx'); + console.log('STRIPE_PRO_YEARLY_PRICE_ID=price_xxxxx'); + console.log('STRIPE_ENTERPRISE_MONTHLY_PRICE_ID=price_xxxxx'); + console.log('STRIPE_ENTERPRISE_YEARLY_PRICE_ID=price_xxxxx'); + + } catch (error) { + console.error('Error fetching prices:', error.message); + } +} + +listPrices(); diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index 3bcde64..cc54c5c 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -19,6 +19,7 @@ import { WebhooksModule } from './application/webhooks/webhooks.module'; import { GDPRModule } from './application/gdpr/gdpr.module'; import { CsvBookingsModule } from './application/csv-bookings.module'; import { AdminModule } from './application/admin/admin.module'; +import { SubscriptionsModule } from './application/subscriptions/subscriptions.module'; import { CacheModule } from './infrastructure/cache/cache.module'; import { CarrierModule } from './infrastructure/carriers/carrier.module'; import { SecurityModule } from './infrastructure/security/security.module'; @@ -56,6 +57,15 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard'; SMTP_PASS: Joi.string().required(), SMTP_FROM: Joi.string().email().default('noreply@xpeditis.com'), SMTP_SECURE: Joi.boolean().default(false), + // 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(), }), }), @@ -117,6 +127,7 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard'; WebhooksModule, GDPRModule, AdminModule, + SubscriptionsModule, ], controllers: [], providers: [ diff --git a/apps/backend/src/application/auth/auth.module.ts b/apps/backend/src/application/auth/auth.module.ts index bdb3bbf..98af8bc 100644 --- a/apps/backend/src/application/auth/auth.module.ts +++ b/apps/backend/src/application/auth/auth.module.ts @@ -20,6 +20,7 @@ import { InvitationTokenOrmEntity } from '../../infrastructure/persistence/typeo import { InvitationService } from '../services/invitation.service'; import { InvitationsController } from '../controllers/invitations.controller'; import { EmailModule } from '../../infrastructure/email/email.module'; +import { SubscriptionsModule } from '../subscriptions/subscriptions.module'; @Module({ imports: [ @@ -43,6 +44,9 @@ import { EmailModule } from '../../infrastructure/email/email.module'; // Email module for sending invitations EmailModule, + + // Subscriptions module for license checks + SubscriptionsModule, ], controllers: [AuthController, InvitationsController], providers: [ diff --git a/apps/backend/src/application/auth/auth.service.ts b/apps/backend/src/application/auth/auth.service.ts index f01e9f9..cbcc17d 100644 --- a/apps/backend/src/application/auth/auth.service.ts +++ b/apps/backend/src/application/auth/auth.service.ts @@ -18,6 +18,7 @@ import { import { Organization } from '@domain/entities/organization.entity'; import { v4 as uuidv4 } from 'uuid'; import { RegisterOrganizationDto } from '../dto/auth-login.dto'; +import { SubscriptionService } from '../services/subscription.service'; export interface JwtPayload { sub: string; // user ID @@ -37,7 +38,8 @@ export class AuthService { @Inject(ORGANIZATION_REPOSITORY) private readonly organizationRepository: OrganizationRepository, private readonly jwtService: JwtService, - private readonly configService: ConfigService + private readonly configService: ConfigService, + private readonly subscriptionService: SubscriptionService, ) {} /** @@ -100,6 +102,16 @@ export class AuthService { const savedUser = await this.userRepository.save(user); + // Allocate a license for the new user + try { + await this.subscriptionService.allocateLicense(savedUser.id, finalOrganizationId); + this.logger.log(`License allocated for user: ${email}`); + } catch (error) { + this.logger.error(`Failed to allocate license for user ${email}:`, error); + // Note: We don't throw here because the user is already created. + // The license check should happen before invitation. + } + const tokens = await this.generateTokens(savedUser); this.logger.log(`User registered successfully: ${email}`); diff --git a/apps/backend/src/application/controllers/bookings.controller.ts b/apps/backend/src/application/controllers/bookings.controller.ts index c2279b3..386c25d 100644 --- a/apps/backend/src/application/controllers/bookings.controller.ts +++ b/apps/backend/src/application/controllers/bookings.controller.ts @@ -372,14 +372,20 @@ export class BookingsController { const endIndex = startIndex + pageSize; const paginatedBookings = filteredBookings.slice(startIndex, endIndex); - // Fetch rate quotes for all bookings - const bookingsWithQuotes = await Promise.all( + // Fetch rate quotes for all bookings (filter out those with missing rate quotes) + const bookingsWithQuotesRaw = await Promise.all( paginatedBookings.map(async (booking: any) => { const rateQuote = await this.rateQuoteRepository.findById(booking.rateQuoteId); - return { booking, rateQuote: rateQuote! }; + return { booking, rateQuote }; }) ); + // Filter out bookings with missing rate quotes to avoid null pointer errors + const bookingsWithQuotes = bookingsWithQuotesRaw.filter( + (item): item is { booking: any; rateQuote: NonNullable } => + item.rateQuote !== null && item.rateQuote !== undefined + ); + // Convert to DTOs const bookingDtos = BookingMapper.toListItemDtoArray(bookingsWithQuotes); @@ -440,14 +446,21 @@ export class BookingsController { ); // Map ORM entities to domain and fetch rate quotes - const bookingsWithQuotes = await Promise.all( + const bookingsWithQuotesRaw = await Promise.all( bookingOrms.map(async bookingOrm => { const booking = await this.bookingRepository.findById(bookingOrm.id); const rateQuote = await this.rateQuoteRepository.findById(bookingOrm.rateQuoteId); - return { booking: booking!, rateQuote: rateQuote! }; + return { booking, rateQuote }; }) ); + // 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 + ); + // Convert to DTOs const bookingDtos = bookingsWithQuotes.map(({ booking, rateQuote }) => BookingMapper.toDto(booking, rateQuote) @@ -487,8 +500,10 @@ export class BookingsController { // Apply filters bookings = this.applyFilters(bookings, filter); - // Sort bookings - bookings = this.sortBookings(bookings, filter.sortBy!, filter.sortOrder!); + // Sort bookings (use defaults if not provided) + const sortBy = filter.sortBy || 'createdAt'; + const sortOrder = filter.sortOrder || 'desc'; + bookings = this.sortBookings(bookings, sortBy, sortOrder); // Total count before pagination const total = bookings.length; @@ -498,14 +513,20 @@ export class BookingsController { const endIndex = startIndex + (filter.pageSize || 20); const paginatedBookings = bookings.slice(startIndex, endIndex); - // Fetch rate quotes - const bookingsWithQuotes = await Promise.all( + // Fetch rate quotes (filter out those with missing rate quotes) + const bookingsWithQuotesRaw = await Promise.all( paginatedBookings.map(async booking => { const rateQuote = await this.rateQuoteRepository.findById(booking.rateQuoteId); - return { booking, rateQuote: rateQuote! }; + return { booking, rateQuote }; }) ); + // Filter out bookings with missing rate quotes to avoid null pointer errors + const bookingsWithQuotes = bookingsWithQuotesRaw.filter( + (item): item is { booking: any; rateQuote: NonNullable } => + item.rateQuote !== null && item.rateQuote !== undefined + ); + // Convert to DTOs const bookingDtos = BookingMapper.toListItemDtoArray(bookingsWithQuotes); @@ -562,14 +583,20 @@ export class BookingsController { bookings = this.applyFilters(bookings, filter); } - // Fetch rate quotes - const bookingsWithQuotes = await Promise.all( + // Fetch rate quotes (filter out those with missing rate quotes) + const bookingsWithQuotesRaw = await Promise.all( bookings.map(async booking => { const rateQuote = await this.rateQuoteRepository.findById(booking.rateQuoteId); - return { booking, rateQuote: rateQuote! }; + return { booking, rateQuote }; }) ); + // Filter out bookings with missing rate quotes to avoid null pointer errors + const bookingsWithQuotes = bookingsWithQuotesRaw.filter( + (item): item is { booking: any; rateQuote: NonNullable } => + item.rateQuote !== null && item.rateQuote !== undefined + ); + // Generate export file const exportResult = await this.exportService.exportBookings( bookingsWithQuotes, diff --git a/apps/backend/src/application/controllers/csv-bookings.controller.ts b/apps/backend/src/application/controllers/csv-bookings.controller.ts index 9b085cd..6ee0ad0 100644 --- a/apps/backend/src/application/controllers/csv-bookings.controller.ts +++ b/apps/backend/src/application/controllers/csv-bookings.controller.ts @@ -3,6 +3,7 @@ import { Post, Get, Patch, + Delete, Body, Param, Query, @@ -39,12 +40,20 @@ import { * CSV Bookings Controller * * Handles HTTP requests for CSV-based booking requests + * + * IMPORTANT: Route order matters in NestJS! + * Static routes MUST come BEFORE parameterized routes. + * Otherwise, `:id` will capture "stats", "organization", etc. */ @ApiTags('CSV Bookings') @Controller('csv-bookings') export class CsvBookingsController { constructor(private readonly csvBookingService: CsvBookingService) {} + // ============================================================================ + // STATIC ROUTES (must come FIRST) + // ============================================================================ + /** * Create a new CSV booking request * @@ -151,6 +160,112 @@ export class CsvBookingsController { return await this.csvBookingService.createBooking(sanitizedDto, files, userId, organizationId); } + /** + * Get current user's bookings (paginated) + * + * GET /api/v1/csv-bookings + */ + @Get() + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Get user bookings', + description: 'Retrieve all bookings for the authenticated user with pagination.', + }) + @ApiQuery({ name: 'page', required: false, type: Number, example: 1 }) + @ApiQuery({ name: 'limit', required: false, type: Number, example: 10 }) + @ApiResponse({ + status: 200, + description: 'Bookings retrieved successfully', + type: CsvBookingListResponseDto, + }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + async getUserBookings( + @Request() req: any, + @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, + @Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number + ): Promise { + const userId = req.user.id; + return await this.csvBookingService.getUserBookings(userId, page, limit); + } + + /** + * Get booking statistics for user + * + * GET /api/v1/csv-bookings/stats/me + */ + @Get('stats/me') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Get user booking statistics', + description: + 'Get aggregated statistics for the authenticated user (pending, accepted, rejected, cancelled).', + }) + @ApiResponse({ + status: 200, + description: 'Statistics retrieved successfully', + type: CsvBookingStatsDto, + }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + async getUserStats(@Request() req: any): Promise { + const userId = req.user.id; + return await this.csvBookingService.getUserStats(userId); + } + + /** + * Get organization booking statistics + * + * GET /api/v1/csv-bookings/stats/organization + */ + @Get('stats/organization') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Get organization booking statistics', + description: "Get aggregated statistics for the user's organization. For managers/admins.", + }) + @ApiResponse({ + status: 200, + description: 'Statistics retrieved successfully', + type: CsvBookingStatsDto, + }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + async getOrganizationStats(@Request() req: any): Promise { + const organizationId = req.user.organizationId; + return await this.csvBookingService.getOrganizationStats(organizationId); + } + + /** + * Get organization bookings (for managers/admins) + * + * GET /api/v1/csv-bookings/organization/all + */ + @Get('organization/all') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Get organization bookings', + description: + "Retrieve all bookings for the user's organization with pagination. For managers/admins.", + }) + @ApiQuery({ name: 'page', required: false, type: Number, example: 1 }) + @ApiQuery({ name: 'limit', required: false, type: Number, example: 10 }) + @ApiResponse({ + status: 200, + description: 'Organization bookings retrieved successfully', + type: CsvBookingListResponseDto, + }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + async getOrganizationBookings( + @Request() req: any, + @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, + @Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number + ): Promise { + const organizationId = req.user.organizationId; + return await this.csvBookingService.getOrganizationBookings(organizationId, page, limit); + } + /** * Accept a booking request (PUBLIC - token-based) * @@ -226,10 +341,17 @@ export class CsvBookingsController { }; } + // ============================================================================ + // PARAMETERIZED ROUTES (must come LAST) + // ============================================================================ + /** * Get a booking by ID * * GET /api/v1/csv-bookings/:id + * + * IMPORTANT: This route MUST be after all static GET routes + * Otherwise it will capture "stats", "organization", etc. */ @Get(':id') @UseGuards(JwtAuthGuard) @@ -252,59 +374,6 @@ export class CsvBookingsController { return await this.csvBookingService.getBookingById(id, userId, carrierId); } - /** - * Get current user's bookings (paginated) - * - * GET /api/v1/csv-bookings - */ - @Get() - @UseGuards(JwtAuthGuard) - @ApiBearerAuth() - @ApiOperation({ - summary: 'Get user bookings', - description: 'Retrieve all bookings for the authenticated user with pagination.', - }) - @ApiQuery({ name: 'page', required: false, type: Number, example: 1 }) - @ApiQuery({ name: 'limit', required: false, type: Number, example: 10 }) - @ApiResponse({ - status: 200, - description: 'Bookings retrieved successfully', - type: CsvBookingListResponseDto, - }) - @ApiResponse({ status: 401, description: 'Unauthorized' }) - async getUserBookings( - @Request() req: any, - @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, - @Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number - ): Promise { - const userId = req.user.id; - return await this.csvBookingService.getUserBookings(userId, page, limit); - } - - /** - * Get booking statistics for user - * - * GET /api/v1/csv-bookings/stats/me - */ - @Get('stats/me') - @UseGuards(JwtAuthGuard) - @ApiBearerAuth() - @ApiOperation({ - summary: 'Get user booking statistics', - description: - 'Get aggregated statistics for the authenticated user (pending, accepted, rejected, cancelled).', - }) - @ApiResponse({ - status: 200, - description: 'Statistics retrieved successfully', - type: CsvBookingStatsDto, - }) - @ApiResponse({ status: 401, description: 'Unauthorized' }) - async getUserStats(@Request() req: any): Promise { - const userId = req.user.id; - return await this.csvBookingService.getUserStats(userId); - } - /** * Cancel a booking (user action) * @@ -335,55 +404,165 @@ export class CsvBookingsController { } /** - * Get organization bookings (for managers/admins) + * Add documents to an existing booking * - * GET /api/v1/csv-bookings/organization/all + * POST /api/v1/csv-bookings/:id/documents */ - @Get('organization/all') + @Post(':id/documents') @UseGuards(JwtAuthGuard) @ApiBearerAuth() + @UseInterceptors(FilesInterceptor('documents', 10)) + @ApiConsumes('multipart/form-data') @ApiOperation({ - summary: 'Get organization bookings', + summary: 'Add documents to an existing booking', description: - "Retrieve all bookings for the user's organization with pagination. For managers/admins.", + 'Upload additional documents to a pending booking. Only the booking owner can add documents.', + }) + @ApiParam({ name: 'id', description: 'Booking ID (UUID)' }) + @ApiBody({ + schema: { + type: 'object', + properties: { + documents: { + type: 'array', + items: { type: 'string', format: 'binary' }, + description: 'Documents to add (max 10 files)', + }, + }, + }, }) - @ApiQuery({ name: 'page', required: false, type: Number, example: 1 }) - @ApiQuery({ name: 'limit', required: false, type: Number, example: 10 }) @ApiResponse({ status: 200, - description: 'Organization bookings retrieved successfully', - type: CsvBookingListResponseDto, + description: 'Documents added successfully', + schema: { + type: 'object', + properties: { + success: { type: 'boolean', example: true }, + message: { type: 'string', example: 'Documents added successfully' }, + documentsAdded: { type: 'number', example: 2 }, + }, + }, }) + @ApiResponse({ status: 400, description: 'Invalid request or booking cannot be modified' }) @ApiResponse({ status: 401, description: 'Unauthorized' }) - async getOrganizationBookings( - @Request() req: any, - @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, - @Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number - ): Promise { - const organizationId = req.user.organizationId; - return await this.csvBookingService.getOrganizationBookings(organizationId, page, limit); + @ApiResponse({ status: 404, description: 'Booking not found' }) + async addDocuments( + @Param('id') id: string, + @UploadedFiles() files: Express.Multer.File[], + @Request() req: any + ) { + if (!files || files.length === 0) { + throw new BadRequestException('At least one document is required'); + } + + const userId = req.user.id; + return await this.csvBookingService.addDocuments(id, files, userId); } /** - * Get organization booking statistics + * Replace a document in a booking * - * GET /api/v1/csv-bookings/stats/organization + * PUT /api/v1/csv-bookings/:bookingId/documents/:documentId */ - @Get('stats/organization') + @Patch(':bookingId/documents/:documentId') @UseGuards(JwtAuthGuard) @ApiBearerAuth() + @UseInterceptors(FilesInterceptor('document', 1)) + @ApiConsumes('multipart/form-data') @ApiOperation({ - summary: 'Get organization booking statistics', - description: "Get aggregated statistics for the user's organization. For managers/admins.", + summary: 'Replace a document in a booking', + description: + 'Replace an existing document with a new one. Only the booking owner can replace documents.', + }) + @ApiParam({ name: 'bookingId', description: 'Booking ID (UUID)' }) + @ApiParam({ name: 'documentId', description: 'Document ID (UUID) to replace' }) + @ApiBody({ + schema: { + type: 'object', + properties: { + document: { + type: 'string', + format: 'binary', + description: 'New document file to replace the existing one', + }, + }, + }, }) @ApiResponse({ status: 200, - description: 'Statistics retrieved successfully', - type: CsvBookingStatsDto, + description: 'Document replaced successfully', + schema: { + type: 'object', + properties: { + success: { type: 'boolean', example: true }, + message: { type: 'string', example: 'Document replaced successfully' }, + newDocument: { + type: 'object', + properties: { + id: { type: 'string' }, + type: { type: 'string' }, + fileName: { type: 'string' }, + filePath: { type: 'string' }, + mimeType: { type: 'string' }, + size: { type: 'number' }, + uploadedAt: { type: 'string', format: 'date-time' }, + }, + }, + }, + }, }) + @ApiResponse({ status: 400, description: 'Invalid request - missing file' }) @ApiResponse({ status: 401, description: 'Unauthorized' }) - async getOrganizationStats(@Request() req: any): Promise { - const organizationId = req.user.organizationId; - return await this.csvBookingService.getOrganizationStats(organizationId); + @ApiResponse({ status: 404, description: 'Booking or document not found' }) + async replaceDocument( + @Param('bookingId') bookingId: string, + @Param('documentId') documentId: string, + @UploadedFiles() files: Express.Multer.File[], + @Request() req: any + ) { + if (!files || files.length === 0) { + throw new BadRequestException('A document file is required'); + } + + const userId = req.user.id; + return await this.csvBookingService.replaceDocument(bookingId, documentId, files[0], userId); + } + + /** + * Delete a document from a booking + * + * DELETE /api/v1/csv-bookings/:bookingId/documents/:documentId + */ + @Delete(':bookingId/documents/:documentId') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Delete a document from a booking', + description: + 'Remove a document from a pending booking. Only the booking owner can delete documents.', + }) + @ApiParam({ name: 'bookingId', description: 'Booking ID (UUID)' }) + @ApiParam({ name: 'documentId', description: 'Document ID (UUID)' }) + @ApiResponse({ + status: 200, + description: 'Document deleted successfully', + schema: { + type: 'object', + properties: { + success: { type: 'boolean', example: true }, + message: { type: 'string', example: 'Document deleted successfully' }, + }, + }, + }) + @ApiResponse({ status: 400, description: 'Booking cannot be modified (not pending)' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 404, description: 'Booking or document not found' }) + async deleteDocument( + @Param('bookingId') bookingId: string, + @Param('documentId') documentId: string, + @Request() req: any + ) { + const userId = req.user.id; + return await this.csvBookingService.deleteDocument(bookingId, documentId, userId); } } diff --git a/apps/backend/src/application/controllers/subscriptions.controller.ts b/apps/backend/src/application/controllers/subscriptions.controller.ts new file mode 100644 index 0000000..f3e933b --- /dev/null +++ b/apps/backend/src/application/controllers/subscriptions.controller.ts @@ -0,0 +1,266 @@ +/** + * Subscriptions Controller + * + * Handles subscription management endpoints: + * - GET /subscriptions - Get subscription overview + * - GET /subscriptions/plans - Get all available plans + * - GET /subscriptions/can-invite - Check if can invite users + * - POST /subscriptions/checkout - Create Stripe checkout session + * - POST /subscriptions/portal - Create Stripe portal session + * - POST /subscriptions/webhook - Handle Stripe webhooks + */ + +import { + Controller, + Get, + Post, + Body, + UseGuards, + HttpCode, + HttpStatus, + Logger, + Headers, + RawBodyRequest, + Req, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiBearerAuth, + ApiExcludeEndpoint, +} from '@nestjs/swagger'; +import { Request } from 'express'; +import { SubscriptionService } from '../services/subscription.service'; +import { + CreateCheckoutSessionDto, + CreatePortalSessionDto, + SyncSubscriptionDto, + SubscriptionOverviewResponseDto, + CanInviteResponseDto, + CheckoutSessionResponseDto, + PortalSessionResponseDto, + AllPlansResponseDto, +} from '../dto/subscription.dto'; +import { JwtAuthGuard } from '../guards/jwt-auth.guard'; +import { RolesGuard } from '../guards/roles.guard'; +import { Roles } from '../decorators/roles.decorator'; +import { CurrentUser, UserPayload } from '../decorators/current-user.decorator'; +import { Public } from '../decorators/public.decorator'; + +@ApiTags('Subscriptions') +@Controller('subscriptions') +export class SubscriptionsController { + private readonly logger = new Logger(SubscriptionsController.name); + + constructor(private readonly subscriptionService: SubscriptionService) {} + + /** + * Get subscription overview for current organization + */ + @Get() + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('admin', 'manager') + @ApiBearerAuth() + @ApiOperation({ + summary: 'Get subscription overview', + description: + 'Get the subscription details including licenses for the current organization. Admin/manager only.', + }) + @ApiResponse({ + status: 200, + description: 'Subscription overview retrieved successfully', + type: SubscriptionOverviewResponseDto, + }) + @ApiResponse({ + status: 403, + description: 'Forbidden - requires admin or manager role', + }) + async getSubscriptionOverview( + @CurrentUser() user: UserPayload, + ): Promise { + this.logger.log(`[User: ${user.email}] Getting subscription overview`); + return this.subscriptionService.getSubscriptionOverview(user.organizationId); + } + + /** + * Get all available plans + */ + @Get('plans') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Get all plans', + description: 'Get details of all available subscription plans.', + }) + @ApiResponse({ + status: 200, + description: 'Plans retrieved successfully', + type: AllPlansResponseDto, + }) + getAllPlans(): AllPlansResponseDto { + this.logger.log('Getting all subscription plans'); + return this.subscriptionService.getAllPlans(); + } + + /** + * Check if organization can invite more users + */ + @Get('can-invite') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('admin', 'manager') + @ApiBearerAuth() + @ApiOperation({ + summary: 'Check license availability', + description: + 'Check if the organization can invite more users based on license availability. Admin/manager only.', + }) + @ApiResponse({ + status: 200, + description: 'License availability check result', + type: CanInviteResponseDto, + }) + @ApiResponse({ + status: 403, + description: 'Forbidden - requires admin or manager role', + }) + async canInvite(@CurrentUser() user: UserPayload): Promise { + this.logger.log(`[User: ${user.email}] Checking license availability`); + return this.subscriptionService.canInviteUser(user.organizationId); + } + + /** + * Create Stripe Checkout session for subscription upgrade + */ + @Post('checkout') + @HttpCode(HttpStatus.OK) + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('admin', 'manager') + @ApiBearerAuth() + @ApiOperation({ + summary: 'Create checkout session', + description: + 'Create a Stripe Checkout session for upgrading subscription. Admin/Manager only.', + }) + @ApiResponse({ + status: 200, + description: 'Checkout session created successfully', + type: CheckoutSessionResponseDto, + }) + @ApiResponse({ + status: 400, + description: 'Bad request - invalid plan or already subscribed', + }) + @ApiResponse({ + status: 403, + description: 'Forbidden - requires admin or manager role', + }) + async createCheckoutSession( + @Body() dto: CreateCheckoutSessionDto, + @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, + ); + } + + /** + * Create Stripe Customer Portal session + */ + @Post('portal') + @HttpCode(HttpStatus.OK) + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('admin', 'manager') + @ApiBearerAuth() + @ApiOperation({ + summary: 'Create portal session', + description: + 'Create a Stripe Customer Portal session for subscription management. Admin/Manager only.', + }) + @ApiResponse({ + status: 200, + description: 'Portal session created successfully', + type: PortalSessionResponseDto, + }) + @ApiResponse({ + status: 400, + description: 'Bad request - no Stripe customer found', + }) + @ApiResponse({ + status: 403, + description: 'Forbidden - requires admin or manager role', + }) + async createPortalSession( + @Body() dto: CreatePortalSessionDto, + @CurrentUser() user: UserPayload, + ): Promise { + this.logger.log(`[User: ${user.email}] Creating portal session`); + return this.subscriptionService.createPortalSession(user.organizationId, dto); + } + + /** + * Sync subscription from Stripe + * Useful when webhooks are not available (e.g., local development) + */ + @Post('sync') + @HttpCode(HttpStatus.OK) + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('admin', 'manager') + @ApiBearerAuth() + @ApiOperation({ + summary: 'Sync subscription from Stripe', + description: + 'Manually sync subscription data from Stripe. Useful when webhooks are not working (local dev). Pass sessionId after checkout to sync new subscription. Admin/Manager only.', + }) + @ApiResponse({ + status: 200, + description: 'Subscription synced successfully', + type: SubscriptionOverviewResponseDto, + }) + @ApiResponse({ + status: 400, + description: 'Bad request - no Stripe subscription found', + }) + @ApiResponse({ + status: 403, + description: 'Forbidden - requires admin or manager role', + }) + async syncFromStripe( + @Body() dto: SyncSubscriptionDto, + @CurrentUser() user: UserPayload, + ): Promise { + this.logger.log( + `[User: ${user.email}] Syncing subscription from Stripe${dto.sessionId ? ` (sessionId: ${dto.sessionId})` : ''}`, + ); + return this.subscriptionService.syncFromStripe(user.organizationId, dto.sessionId); + } + + /** + * Handle Stripe webhook events + */ + @Post('webhook') + @Public() + @HttpCode(HttpStatus.OK) + @ApiExcludeEndpoint() + async handleWebhook( + @Headers('stripe-signature') signature: string, + @Req() req: RawBodyRequest, + ): Promise<{ received: boolean }> { + const rawBody = req.rawBody; + if (!rawBody) { + this.logger.error('No raw body found in request'); + return { received: false }; + } + + try { + await this.subscriptionService.handleStripeWebhook(rawBody, signature); + return { received: true }; + } catch (error) { + this.logger.error('Webhook processing failed', error); + return { received: false }; + } + } +} diff --git a/apps/backend/src/application/controllers/users.controller.ts b/apps/backend/src/application/controllers/users.controller.ts index 1609a16..99793db 100644 --- a/apps/backend/src/application/controllers/users.controller.ts +++ b/apps/backend/src/application/controllers/users.controller.ts @@ -49,6 +49,7 @@ import { Roles } from '../decorators/roles.decorator'; import { v4 as uuidv4 } from 'uuid'; import * as argon2 from 'argon2'; import * as crypto from 'crypto'; +import { SubscriptionService } from '../services/subscription.service'; /** * Users Controller @@ -68,7 +69,10 @@ import * as crypto from 'crypto'; export class UsersController { private readonly logger = new Logger(UsersController.name); - constructor(@Inject(USER_REPOSITORY) private readonly userRepository: UserRepository) {} + constructor( + @Inject(USER_REPOSITORY) private readonly userRepository: UserRepository, + private readonly subscriptionService: SubscriptionService, + ) {} /** * Create/Invite a new user @@ -273,8 +277,21 @@ export class UsersController { if (dto.isActive !== undefined) { if (dto.isActive) { user.activate(); + // Reallocate license if reactivating user + try { + await this.subscriptionService.allocateLicense(id, user.organizationId); + this.logger.log(`License reallocated for reactivated user: ${id}`); + } 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.', + ); + } } else { user.deactivate(); + // Revoke license when deactivating user + await this.subscriptionService.revokeLicense(id); + this.logger.log(`License revoked for deactivated user: ${id}`); } } @@ -321,6 +338,10 @@ export class UsersController { throw new NotFoundException(`User ${id} not found`); } + // Revoke license before deleting user + await this.subscriptionService.revokeLicense(id); + this.logger.log(`License revoked for user being deleted: ${id}`); + // Permanently delete user from database await this.userRepository.deleteById(id); diff --git a/apps/backend/src/application/dto/subscription.dto.ts b/apps/backend/src/application/dto/subscription.dto.ts new file mode 100644 index 0000000..a8e7f75 --- /dev/null +++ b/apps/backend/src/application/dto/subscription.dto.ts @@ -0,0 +1,378 @@ +/** + * Subscription DTOs + * + * Data Transfer Objects for subscription management API + */ + +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsString, + IsEnum, + IsNotEmpty, + IsUrl, + IsOptional, + IsBoolean, + IsInt, + Min, +} from 'class-validator'; + +/** + * Subscription plan types + */ +export enum SubscriptionPlanDto { + FREE = 'FREE', + STARTER = 'STARTER', + PRO = 'PRO', + ENTERPRISE = 'ENTERPRISE', +} + +/** + * Subscription status types + */ +export enum SubscriptionStatusDto { + ACTIVE = 'ACTIVE', + PAST_DUE = 'PAST_DUE', + CANCELED = 'CANCELED', + INCOMPLETE = 'INCOMPLETE', + INCOMPLETE_EXPIRED = 'INCOMPLETE_EXPIRED', + TRIALING = 'TRIALING', + UNPAID = 'UNPAID', + PAUSED = 'PAUSED', +} + +/** + * Billing interval types + */ +export enum BillingIntervalDto { + MONTHLY = 'monthly', + YEARLY = 'yearly', +} + +/** + * Create Checkout Session DTO + */ +export class CreateCheckoutSessionDto { + @ApiProperty({ + example: SubscriptionPlanDto.STARTER, + description: 'The subscription plan to purchase', + enum: SubscriptionPlanDto, + }) + @IsEnum(SubscriptionPlanDto) + plan: SubscriptionPlanDto; + + @ApiProperty({ + example: BillingIntervalDto.MONTHLY, + description: 'Billing interval (monthly or yearly)', + enum: BillingIntervalDto, + }) + @IsEnum(BillingIntervalDto) + billingInterval: BillingIntervalDto; + + @ApiPropertyOptional({ + example: 'https://app.xpeditis.com/dashboard/settings/subscription?success=true', + description: 'URL to redirect to after successful payment', + }) + @IsUrl() + @IsOptional() + successUrl?: string; + + @ApiPropertyOptional({ + example: 'https://app.xpeditis.com/dashboard/settings/subscription?canceled=true', + description: 'URL to redirect to if payment is canceled', + }) + @IsUrl() + @IsOptional() + cancelUrl?: string; +} + +/** + * Create Portal Session DTO + */ +export class CreatePortalSessionDto { + @ApiPropertyOptional({ + example: 'https://app.xpeditis.com/dashboard/settings/subscription', + description: 'URL to return to after using the portal', + }) + @IsUrl() + @IsOptional() + returnUrl?: string; +} + +/** + * Sync Subscription DTO + */ +export class SyncSubscriptionDto { + @ApiPropertyOptional({ + example: 'cs_test_a1b2c3d4e5f6g7h8', + description: 'Stripe checkout session ID (used after checkout completes)', + }) + @IsString() + @IsOptional() + sessionId?: string; +} + +/** + * Checkout Session Response DTO + */ +export class CheckoutSessionResponseDto { + @ApiProperty({ + example: 'cs_test_a1b2c3d4e5f6g7h8', + description: 'Stripe checkout session ID', + }) + sessionId: string; + + @ApiProperty({ + example: 'https://checkout.stripe.com/pay/cs_test_a1b2c3', + description: 'URL to redirect user to for payment', + }) + sessionUrl: string; +} + +/** + * Portal Session Response DTO + */ +export class PortalSessionResponseDto { + @ApiProperty({ + example: 'https://billing.stripe.com/session/test_YWNjdF8x', + description: 'URL to redirect user to for subscription management', + }) + sessionUrl: string; +} + +/** + * License Response DTO + */ +export class LicenseResponseDto { + @ApiProperty({ + example: '550e8400-e29b-41d4-a716-446655440000', + description: 'License ID', + }) + id: string; + + @ApiProperty({ + example: '550e8400-e29b-41d4-a716-446655440001', + description: 'User ID', + }) + userId: string; + + @ApiProperty({ + example: 'john.doe@example.com', + description: 'User email', + }) + userEmail: string; + + @ApiProperty({ + example: 'John Doe', + description: 'User full name', + }) + userName: string; + + @ApiProperty({ + example: 'ADMIN', + description: 'User role (ADMIN users have unlimited licenses)', + }) + userRole: string; + + @ApiProperty({ + example: 'ACTIVE', + description: 'License status', + }) + status: string; + + @ApiProperty({ + example: '2025-01-15T10:00:00Z', + description: 'When the license was assigned', + }) + assignedAt: Date; + + @ApiPropertyOptional({ + example: '2025-02-15T10:00:00Z', + description: 'When the license was revoked (if applicable)', + }) + revokedAt?: Date; +} + +/** + * Plan Details DTO + */ +export class PlanDetailsDto { + @ApiProperty({ + example: SubscriptionPlanDto.STARTER, + description: 'Plan identifier', + enum: SubscriptionPlanDto, + }) + plan: SubscriptionPlanDto; + + @ApiProperty({ + example: 'Starter', + description: 'Plan display name', + }) + name: string; + + @ApiProperty({ + example: 5, + description: 'Maximum number of licenses (-1 for unlimited)', + }) + maxLicenses: number; + + @ApiProperty({ + example: 49, + description: 'Monthly price in EUR', + }) + monthlyPriceEur: number; + + @ApiProperty({ + example: 470, + description: 'Yearly price in EUR', + }) + yearlyPriceEur: number; + + @ApiProperty({ + example: ['Up to 5 users', 'Advanced rate search', 'CSV imports'], + description: 'List of features included in this plan', + type: [String], + }) + features: string[]; +} + +/** + * Subscription Response DTO + */ +export class SubscriptionResponseDto { + @ApiProperty({ + example: '550e8400-e29b-41d4-a716-446655440000', + description: 'Subscription ID', + }) + id: string; + + @ApiProperty({ + example: '550e8400-e29b-41d4-a716-446655440001', + description: 'Organization ID', + }) + organizationId: string; + + @ApiProperty({ + example: SubscriptionPlanDto.STARTER, + description: 'Current subscription plan', + enum: SubscriptionPlanDto, + }) + plan: SubscriptionPlanDto; + + @ApiProperty({ + description: 'Details about the current plan', + type: PlanDetailsDto, + }) + planDetails: PlanDetailsDto; + + @ApiProperty({ + example: SubscriptionStatusDto.ACTIVE, + description: 'Current subscription status', + enum: SubscriptionStatusDto, + }) + status: SubscriptionStatusDto; + + @ApiProperty({ + example: 3, + description: 'Number of licenses currently in use', + }) + usedLicenses: number; + + @ApiProperty({ + example: 5, + description: 'Maximum licenses available (-1 for unlimited)', + }) + maxLicenses: number; + + @ApiProperty({ + example: 2, + description: 'Number of licenses available', + }) + availableLicenses: number; + + @ApiProperty({ + example: false, + description: 'Whether the subscription is scheduled for cancellation', + }) + cancelAtPeriodEnd: boolean; + + @ApiPropertyOptional({ + example: '2025-01-01T00:00:00Z', + description: 'Start of current billing period', + }) + currentPeriodStart?: Date; + + @ApiPropertyOptional({ + example: '2025-02-01T00:00:00Z', + description: 'End of current billing period', + }) + currentPeriodEnd?: Date; + + @ApiProperty({ + example: '2025-01-01T00:00:00Z', + description: 'When the subscription was created', + }) + createdAt: Date; + + @ApiProperty({ + example: '2025-01-15T10:00:00Z', + description: 'When the subscription was last updated', + }) + updatedAt: Date; +} + +/** + * Subscription Overview Response DTO (includes licenses) + */ +export class SubscriptionOverviewResponseDto extends SubscriptionResponseDto { + @ApiProperty({ + description: 'List of active licenses', + type: [LicenseResponseDto], + }) + licenses: LicenseResponseDto[]; +} + +/** + * Can Invite Response DTO + */ +export class CanInviteResponseDto { + @ApiProperty({ + example: true, + description: 'Whether the organization can invite more users', + }) + canInvite: boolean; + + @ApiProperty({ + example: 2, + description: 'Number of available licenses', + }) + availableLicenses: number; + + @ApiProperty({ + example: 3, + description: 'Number of used licenses', + }) + usedLicenses: number; + + @ApiProperty({ + example: 5, + description: 'Maximum licenses allowed (-1 for unlimited)', + }) + maxLicenses: number; + + @ApiPropertyOptional({ + example: 'Upgrade to Starter plan to add more users', + description: 'Message explaining why invitations are blocked', + }) + message?: string; +} + +/** + * All Plans Response DTO + */ +export class AllPlansResponseDto { + @ApiProperty({ + description: 'List of all available plans', + type: [PlanDetailsDto], + }) + plans: PlanDetailsDto[]; +} diff --git a/apps/backend/src/application/services/csv-booking.service.ts b/apps/backend/src/application/services/csv-booking.service.ts index 4e4e21b..a7fc9a4 100644 --- a/apps/backend/src/application/services/csv-booking.service.ts +++ b/apps/backend/src/application/services/csv-booking.service.ts @@ -454,6 +454,219 @@ export class CsvBookingService { } } + /** + * Add documents to an existing booking + */ + async addDocuments( + bookingId: string, + files: Express.Multer.File[], + userId: string + ): Promise<{ success: boolean; message: string; documentsAdded: number }> { + this.logger.log(`Adding ${files.length} documents to booking ${bookingId}`); + + const booking = await this.csvBookingRepository.findById(bookingId); + + if (!booking) { + throw new NotFoundException(`Booking with ID ${bookingId} not found`); + } + + // Verify user owns this booking + if (booking.userId !== userId) { + throw new NotFoundException(`Booking with ID ${bookingId} not found`); + } + + // Verify booking is still pending + if (booking.status !== CsvBookingStatus.PENDING) { + throw new BadRequestException('Cannot add documents to a booking that is not pending'); + } + + // Upload new documents + const newDocuments = await this.uploadDocuments(files, bookingId); + + // Add documents to booking + const updatedDocuments = [...booking.documents, ...newDocuments]; + + // Update booking in database using ORM repository directly + const ormBooking = await this.csvBookingRepository['repository'].findOne({ + where: { id: bookingId }, + }); + + if (ormBooking) { + ormBooking.documents = updatedDocuments.map(doc => ({ + id: doc.id, + type: doc.type, + fileName: doc.fileName, + filePath: doc.filePath, + mimeType: doc.mimeType, + size: doc.size, + uploadedAt: doc.uploadedAt, + })); + await this.csvBookingRepository['repository'].save(ormBooking); + } + + this.logger.log(`Added ${newDocuments.length} documents to booking ${bookingId}`); + + return { + success: true, + message: 'Documents added successfully', + documentsAdded: newDocuments.length, + }; + } + + /** + * Delete a document from a booking + */ + async deleteDocument( + bookingId: string, + documentId: string, + userId: string + ): Promise<{ success: boolean; message: string }> { + this.logger.log(`Deleting document ${documentId} from booking ${bookingId}`); + + const booking = await this.csvBookingRepository.findById(bookingId); + + if (!booking) { + throw new NotFoundException(`Booking with ID ${bookingId} not found`); + } + + // Verify user owns this booking + if (booking.userId !== userId) { + throw new NotFoundException(`Booking with ID ${bookingId} not found`); + } + + // Verify booking is still pending + if (booking.status !== CsvBookingStatus.PENDING) { + throw new BadRequestException('Cannot delete documents from a booking that is not pending'); + } + + // Find the document + const documentIndex = booking.documents.findIndex(doc => doc.id === documentId); + + if (documentIndex === -1) { + throw new NotFoundException(`Document with ID ${documentId} not found`); + } + + // Ensure at least one document remains + if (booking.documents.length <= 1) { + throw new BadRequestException( + 'Cannot delete the last document. At least one document is required.' + ); + } + + // Get the document to delete (for potential S3 cleanup - currently kept for audit) + const _documentToDelete = booking.documents[documentIndex]; + + // Remove document from array + const updatedDocuments = booking.documents.filter(doc => doc.id !== documentId); + + // Update booking in database using ORM repository directly + const ormBooking = await this.csvBookingRepository['repository'].findOne({ + where: { id: bookingId }, + }); + + if (ormBooking) { + ormBooking.documents = updatedDocuments.map(doc => ({ + id: doc.id, + type: doc.type, + fileName: doc.fileName, + filePath: doc.filePath, + mimeType: doc.mimeType, + size: doc.size, + uploadedAt: doc.uploadedAt, + })); + await this.csvBookingRepository['repository'].save(ormBooking); + } + + // Optionally delete from S3 (commented out for safety - keep files for audit) + // try { + // await this.storageAdapter.delete({ + // bucket: 'xpeditis-documents', + // key: documentToDelete.filePath, + // }); + // } catch (error) { + // this.logger.warn(`Failed to delete file from S3: ${documentToDelete.filePath}`); + // } + + this.logger.log(`Deleted document ${documentId} from booking ${bookingId}`); + + return { + success: true, + message: 'Document deleted successfully', + }; + } + + /** + * Replace a document in an existing booking + */ + async replaceDocument( + bookingId: string, + documentId: string, + file: Express.Multer.File, + userId: string + ): Promise<{ success: boolean; message: string; newDocument: any }> { + this.logger.log(`Replacing document ${documentId} in booking ${bookingId}`); + + const booking = await this.csvBookingRepository.findById(bookingId); + + if (!booking) { + throw new NotFoundException(`Booking with ID ${bookingId} not found`); + } + + // Verify user owns this booking + if (booking.userId !== userId) { + throw new NotFoundException(`Booking with ID ${bookingId} not found`); + } + + // Find the document to replace + const documentIndex = booking.documents.findIndex(doc => doc.id === documentId); + + if (documentIndex === -1) { + throw new NotFoundException(`Document with ID ${documentId} not found`); + } + + // Upload the new document + const newDocuments = await this.uploadDocuments([file], bookingId); + const newDocument = newDocuments[0]; + + // Replace the document in the array + const updatedDocuments = [...booking.documents]; + updatedDocuments[documentIndex] = newDocument; + + // Update booking in database using ORM repository directly + const ormBooking = await this.csvBookingRepository['repository'].findOne({ + where: { id: bookingId }, + }); + + if (ormBooking) { + ormBooking.documents = updatedDocuments.map(doc => ({ + id: doc.id, + type: doc.type, + fileName: doc.fileName, + filePath: doc.filePath, + mimeType: doc.mimeType, + size: doc.size, + uploadedAt: doc.uploadedAt, + })); + await this.csvBookingRepository['repository'].save(ormBooking); + } + + this.logger.log(`Replaced document ${documentId} with ${newDocument.id} in booking ${bookingId}`); + + return { + success: true, + message: 'Document replaced successfully', + newDocument: { + id: newDocument.id, + type: newDocument.type, + fileName: newDocument.fileName, + filePath: newDocument.filePath, + mimeType: newDocument.mimeType, + size: newDocument.size, + uploadedAt: newDocument.uploadedAt, + }, + }; + } + /** * Infer document type from filename */ diff --git a/apps/backend/src/application/services/invitation.service.ts b/apps/backend/src/application/services/invitation.service.ts index d3ac67e..1dc6d6e 100644 --- a/apps/backend/src/application/services/invitation.service.ts +++ b/apps/backend/src/application/services/invitation.service.ts @@ -5,6 +5,7 @@ import { ConflictException, NotFoundException, BadRequestException, + ForbiddenException, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { @@ -19,6 +20,7 @@ import { import { EmailPort, EMAIL_PORT } from '@domain/ports/out/email.port'; import { InvitationToken } from '@domain/entities/invitation-token.entity'; import { UserRole } from '@domain/entities/user.entity'; +import { SubscriptionService } from './subscription.service'; import { v4 as uuidv4 } from 'uuid'; import * as crypto from 'crypto'; @@ -35,7 +37,8 @@ export class InvitationService { private readonly organizationRepository: OrganizationRepository, @Inject(EMAIL_PORT) private readonly emailService: EmailPort, - private readonly configService: ConfigService + private readonly configService: ConfigService, + private readonly subscriptionService: SubscriptionService, ) {} /** @@ -65,6 +68,18 @@ export class InvitationService { ); } + // Check if licenses are available for this organization + const canInviteResult = await this.subscriptionService.canInviteUser(organizationId); + if (!canInviteResult.canInvite) { + this.logger.warn( + `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.`, + ); + } + // Generate unique token const token = this.generateToken(); diff --git a/apps/backend/src/application/services/subscription.service.ts b/apps/backend/src/application/services/subscription.service.ts new file mode 100644 index 0000000..90951de --- /dev/null +++ b/apps/backend/src/application/services/subscription.service.ts @@ -0,0 +1,684 @@ +/** + * Subscription Service + * + * Business logic for subscription and license management. + */ + +import { + Injectable, + Inject, + Logger, + NotFoundException, + BadRequestException, + ForbiddenException, +} 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 { + OrganizationRepository, + ORGANIZATION_REPOSITORY, +} from '@domain/ports/out/organization.repository'; +import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.repository'; +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 { SubscriptionStatus } from '@domain/value-objects/subscription-status.vo'; +import { + NoLicensesAvailableException, + SubscriptionNotFoundException, + LicenseAlreadyAssignedException, +} from '@domain/exceptions/subscription.exceptions'; +import { + CreateCheckoutSessionDto, + CreatePortalSessionDto, + SubscriptionOverviewResponseDto, + CanInviteResponseDto, + CheckoutSessionResponseDto, + PortalSessionResponseDto, + LicenseResponseDto, + PlanDetailsDto, + AllPlansResponseDto, + SubscriptionPlanDto, + SubscriptionStatusDto, +} from '../dto/subscription.dto'; + +@Injectable() +export class SubscriptionService { + private readonly logger = new Logger(SubscriptionService.name); + + constructor( + @Inject(SUBSCRIPTION_REPOSITORY) + private readonly subscriptionRepository: SubscriptionRepository, + @Inject(LICENSE_REPOSITORY) + private readonly licenseRepository: LicenseRepository, + @Inject(ORGANIZATION_REPOSITORY) + private readonly organizationRepository: OrganizationRepository, + @Inject(USER_REPOSITORY) + private readonly userRepository: UserRepository, + @Inject(STRIPE_PORT) + private readonly stripeAdapter: StripePort, + private readonly configService: ConfigService, + ) {} + + /** + * Get subscription overview for an organization + */ + async getSubscriptionOverview( + organizationId: string, + ): Promise { + const subscription = await this.getOrCreateSubscription(organizationId); + const activeLicenses = await this.licenseRepository.findActiveBySubscriptionId( + subscription.id, + ); + + // Enrich licenses with user information + const enrichedLicenses = await Promise.all( + 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, + ); + const maxLicenses = subscription.maxLicenses; + const availableLicenses = subscription.isUnlimited() + ? -1 + : Math.max(0, maxLicenses - usedLicenses); + + return { + id: subscription.id, + organizationId: subscription.organizationId, + plan: subscription.plan.value as SubscriptionPlanDto, + planDetails: this.mapPlanToDto(subscription.plan), + status: subscription.status.value as SubscriptionStatusDto, + usedLicenses, + maxLicenses, + availableLicenses, + cancelAtPeriodEnd: subscription.cancelAtPeriodEnd, + currentPeriodStart: subscription.currentPeriodStart || undefined, + currentPeriodEnd: subscription.currentPeriodEnd || undefined, + createdAt: subscription.createdAt, + updatedAt: subscription.updatedAt, + licenses: enrichedLicenses, + }; + } + + /** + * Get all available plans + */ + getAllPlans(): AllPlansResponseDto { + const plans = SubscriptionPlan.getAllPlans().map((plan) => + this.mapPlanToDto(plan), + ); + return { plans }; + } + + /** + * Check if organization can invite more users + * Note: ADMIN users don't count against the license quota + */ + async canInviteUser(organizationId: string): Promise { + const subscription = await this.getOrCreateSubscription(organizationId); + // Count only non-ADMIN licenses - ADMIN users have unlimited licenses + const usedLicenses = await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins( + subscription.id, + ); + + const maxLicenses = subscription.maxLicenses; + const canInvite = + subscription.isActive() && + (subscription.isUnlimited() || usedLicenses < maxLicenses); + + const availableLicenses = subscription.isUnlimited() + ? -1 + : Math.max(0, maxLicenses - usedLicenses); + + let message: string | undefined; + if (!subscription.isActive()) { + message = 'Your subscription is not active. Please update your payment method.'; + } else if (!canInvite) { + message = `You have reached the maximum number of users (${maxLicenses}) for your ${subscription.plan.name} plan. Upgrade to add more users.`; + } + + return { + canInvite, + availableLicenses, + usedLicenses, + maxLicenses, + message, + }; + } + + /** + * Create a Stripe Checkout session for subscription upgrade + */ + async createCheckoutSession( + organizationId: string, + userId: string, + dto: CreateCheckoutSessionDto, + ): Promise { + const organization = await this.organizationRepository.findById(organizationId); + if (!organization) { + throw new NotFoundException('Organization not found'); + } + + const user = await this.userRepository.findById(userId); + if (!user) { + throw new NotFoundException('User not found'); + } + + // Cannot checkout for FREE plan + if (dto.plan === SubscriptionPlanDto.FREE) { + throw new BadRequestException('Cannot create checkout session for FREE plan'); + } + + const subscription = await this.getOrCreateSubscription(organizationId); + + 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`; + + const result = await this.stripeAdapter.createCheckoutSession({ + organizationId, + organizationName: organization.name, + email: user.email, + plan: dto.plan as SubscriptionPlanType, + billingInterval: dto.billingInterval as 'monthly' | 'yearly', + successUrl, + cancelUrl, + customerId: subscription.stripeCustomerId || undefined, + }); + + this.logger.log( + `Created checkout session for organization ${organizationId}, plan ${dto.plan}`, + ); + + return { + sessionId: result.sessionId, + sessionUrl: result.sessionUrl, + }; + } + + /** + * Create a Stripe Customer Portal session + */ + async createPortalSession( + organizationId: string, + dto: CreatePortalSessionDto, + ): Promise { + 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.', + ); + } + + 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, + returnUrl, + }); + + this.logger.log(`Created portal session for organization ${organizationId}`); + + return { + sessionUrl: result.sessionUrl, + }; + } + + /** + * Sync subscription from Stripe + * Useful when webhooks are not available (e.g., local development) + * @param organizationId - The organization ID + * @param sessionId - Optional Stripe checkout session ID (used after checkout completes) + */ + async syncFromStripe( + organizationId: string, + sessionId?: string, + ): Promise { + let subscription = await this.subscriptionRepository.findByOrganizationId( + organizationId, + ); + + if (!subscription) { + subscription = await this.getOrCreateSubscription(organizationId); + } + + let stripeSubscriptionId = subscription.stripeSubscriptionId; + let stripeCustomerId = subscription.stripeCustomerId; + + // 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}`); + const checkoutSession = await this.stripeAdapter.getCheckoutSession(sessionId); + + if (checkoutSession) { + this.logger.log( + `Checkout session found: subscriptionId=${checkoutSession.subscriptionId}, customerId=${checkoutSession.customerId}, status=${checkoutSession.status}`, + ); + + // Always use the subscription ID from the checkout session if available + // This handles upgrades where a new subscription is created + if (checkoutSession.subscriptionId) { + stripeSubscriptionId = checkoutSession.subscriptionId; + } + if (checkoutSession.customerId) { + stripeCustomerId = checkoutSession.customerId; + } + + // Update subscription with customer ID if we got it from checkout session + if (stripeCustomerId && !subscription.stripeCustomerId) { + subscription = subscription.updateStripeCustomerId(stripeCustomerId); + } + } else { + this.logger.warn(`Checkout session ${sessionId} not found`); + } + } + + if (!stripeSubscriptionId) { + this.logger.log(`No Stripe subscription found for organization ${organizationId}`); + // Return current subscription data without syncing + return this.getSubscriptionOverview(organizationId); + } + + // Get fresh data from Stripe + const stripeData = await this.stripeAdapter.getSubscription(stripeSubscriptionId); + + if (!stripeData) { + this.logger.warn(`Could not retrieve Stripe subscription ${stripeSubscriptionId}`); + return this.getSubscriptionOverview(organizationId); + } + + // Map the price ID to our plan + const plan = this.stripeAdapter.mapPriceIdToPlan(stripeData.planId); + let updatedSubscription = subscription; + + if (plan) { + // Count only non-ADMIN licenses - ADMIN users have unlimited licenses + const usedLicenses = await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins( + subscription.id, + ); + const newPlan = SubscriptionPlan.create(plan); + + // Update plan + updatedSubscription = updatedSubscription.updatePlan(newPlan, usedLicenses); + this.logger.log(`Updated plan to ${plan} for organization ${organizationId}`); + } + + // Update Stripe IDs if not already set + if (!updatedSubscription.stripeCustomerId && stripeData.customerId) { + updatedSubscription = updatedSubscription.updateStripeCustomerId(stripeData.customerId); + } + + // Update Stripe subscription data + updatedSubscription = updatedSubscription.updateStripeSubscription({ + stripeSubscriptionId: stripeData.subscriptionId, + currentPeriodStart: stripeData.currentPeriodStart, + currentPeriodEnd: stripeData.currentPeriodEnd, + cancelAtPeriodEnd: stripeData.cancelAtPeriodEnd, + }); + + // Update status + updatedSubscription = updatedSubscription.updateStatus( + SubscriptionStatus.fromStripeStatus(stripeData.status), + ); + + await this.subscriptionRepository.save(updatedSubscription); + + this.logger.log( + `Synced subscription for organization ${organizationId} from Stripe (plan: ${updatedSubscription.plan.value})`, + ); + + return this.getSubscriptionOverview(organizationId); + } + + /** + * Handle Stripe webhook events + */ + async handleStripeWebhook(payload: string | Buffer, signature: string): Promise { + const event = await this.stripeAdapter.constructWebhookEvent(payload, signature); + + this.logger.log(`Processing Stripe webhook event: ${event.type}`); + + switch (event.type) { + case 'checkout.session.completed': + await this.handleCheckoutCompleted(event.data.object); + break; + + case 'customer.subscription.created': + case 'customer.subscription.updated': + await this.handleSubscriptionUpdated(event.data.object); + break; + + case 'customer.subscription.deleted': + await this.handleSubscriptionDeleted(event.data.object); + break; + + case 'invoice.payment_failed': + await this.handlePaymentFailed(event.data.object); + break; + + default: + this.logger.log(`Unhandled Stripe event type: ${event.type}`); + } + } + + /** + * Allocate a license to a user + * Note: ADMIN users always get a license (unlimited) and don't count against the quota + */ + async allocateLicense(userId: string, organizationId: string): Promise { + const subscription = await this.getOrCreateSubscription(organizationId); + + // Check if user already has a license + const existingLicense = await this.licenseRepository.findByUserId(userId); + if (existingLicense?.isActive()) { + throw new LicenseAlreadyAssignedException(userId); + } + + // Get the user to check if they're an ADMIN + const user = await this.userRepository.findById(userId); + const isAdmin = user?.role === 'ADMIN'; + + // ADMIN users have unlimited licenses - skip quota check for them + if (!isAdmin) { + // Count only non-ADMIN licenses for quota check + const usedLicenses = await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins( + subscription.id, + ); + + if (!subscription.canAllocateLicenses(usedLicenses)) { + throw new NoLicensesAvailableException( + organizationId, + usedLicenses, + subscription.maxLicenses, + ); + } + } + + // If there's a revoked license, reactivate it + if (existingLicense?.isRevoked()) { + const reactivatedLicense = existingLicense.reactivate(); + return this.licenseRepository.save(reactivatedLicense); + } + + // Create new license + const license = License.create({ + id: uuidv4(), + subscriptionId: subscription.id, + userId, + }); + + const savedLicense = await this.licenseRepository.save(license); + this.logger.log(`Allocated license ${savedLicense.id} to user ${userId} (isAdmin: ${isAdmin})`); + + return savedLicense; + } + + /** + * Revoke a user's license + */ + async revokeLicense(userId: string): Promise { + const license = await this.licenseRepository.findByUserId(userId); + if (!license) { + this.logger.warn(`No license found for user ${userId}`); + return; + } + + if (license.isRevoked()) { + this.logger.warn(`License for user ${userId} is already revoked`); + return; + } + + const revokedLicense = license.revoke(); + await this.licenseRepository.save(revokedLicense); + + this.logger.log(`Revoked license ${license.id} for user ${userId}`); + } + + /** + * Get or create a subscription for an organization + */ + async getOrCreateSubscription(organizationId: string): Promise { + let subscription = await this.subscriptionRepository.findByOrganizationId( + organizationId, + ); + + if (!subscription) { + // Create FREE subscription for the organization + subscription = Subscription.create({ + id: uuidv4(), + organizationId, + plan: SubscriptionPlan.free(), + }); + + subscription = await this.subscriptionRepository.save(subscription); + this.logger.log( + `Created FREE subscription for organization ${organizationId}`, + ); + } + + return subscription; + } + + // Private helper methods + + private async handleCheckoutCompleted( + session: Record, + ): Promise { + const metadata = session.metadata as Record | undefined; + const organizationId = metadata?.organizationId; + const customerId = session.customer as string; + const subscriptionId = session.subscription as string; + + if (!organizationId || !customerId || !subscriptionId) { + this.logger.warn('Checkout session missing required metadata'); + return; + } + + // Get subscription details from Stripe + const stripeSubscription = await this.stripeAdapter.getSubscription(subscriptionId); + if (!stripeSubscription) { + this.logger.error(`Could not retrieve Stripe subscription ${subscriptionId}`); + return; + } + + // Get or create our subscription + let subscription = await this.getOrCreateSubscription(organizationId); + + // Map the price ID to our plan + const plan = this.stripeAdapter.mapPriceIdToPlan(stripeSubscription.planId); + if (!plan) { + this.logger.error(`Unknown Stripe price ID: ${stripeSubscription.planId}`); + return; + } + + // Update subscription + subscription = subscription.updateStripeCustomerId(customerId); + subscription = subscription.updateStripeSubscription({ + stripeSubscriptionId: subscriptionId, + currentPeriodStart: stripeSubscription.currentPeriodStart, + currentPeriodEnd: stripeSubscription.currentPeriodEnd, + cancelAtPeriodEnd: stripeSubscription.cancelAtPeriodEnd, + }); + subscription = subscription.updatePlan( + SubscriptionPlan.create(plan), + await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins(subscription.id), + ); + subscription = subscription.updateStatus( + SubscriptionStatus.fromStripeStatus(stripeSubscription.status), + ); + + await this.subscriptionRepository.save(subscription); + + this.logger.log( + `Updated subscription for organization ${organizationId} to plan ${plan}`, + ); + } + + private async handleSubscriptionUpdated( + stripeSubscription: Record, + ): Promise { + const subscriptionId = stripeSubscription.id as string; + + let subscription = await this.subscriptionRepository.findByStripeSubscriptionId( + subscriptionId, + ); + + if (!subscription) { + this.logger.warn(`Subscription ${subscriptionId} not found in database`); + return; + } + + // Get fresh data from Stripe + const stripeData = await this.stripeAdapter.getSubscription(subscriptionId); + if (!stripeData) { + this.logger.error(`Could not retrieve Stripe subscription ${subscriptionId}`); + return; + } + + // Map the price ID to our plan + const plan = this.stripeAdapter.mapPriceIdToPlan(stripeData.planId); + if (plan) { + // Count only non-ADMIN licenses - ADMIN users have unlimited licenses + const usedLicenses = await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins( + subscription.id, + ); + const newPlan = SubscriptionPlan.create(plan); + + // Only update plan if it can accommodate current non-ADMIN users + if (newPlan.canAccommodateUsers(usedLicenses)) { + subscription = subscription.updatePlan(newPlan, usedLicenses); + } else { + this.logger.warn( + `Cannot update to plan ${plan} - would exceed license limit`, + ); + } + } + + subscription = subscription.updateStripeSubscription({ + stripeSubscriptionId: subscriptionId, + currentPeriodStart: stripeData.currentPeriodStart, + currentPeriodEnd: stripeData.currentPeriodEnd, + cancelAtPeriodEnd: stripeData.cancelAtPeriodEnd, + }); + subscription = subscription.updateStatus( + SubscriptionStatus.fromStripeStatus(stripeData.status), + ); + + await this.subscriptionRepository.save(subscription); + + this.logger.log(`Updated subscription ${subscriptionId}`); + } + + private async handleSubscriptionDeleted( + stripeSubscription: Record, + ): Promise { + const subscriptionId = stripeSubscription.id as string; + + const subscription = await this.subscriptionRepository.findByStripeSubscriptionId( + subscriptionId, + ); + + if (!subscription) { + this.logger.warn(`Subscription ${subscriptionId} not found in database`); + return; + } + + // Downgrade to FREE plan - count only non-ADMIN licenses + const canceledSubscription = subscription + .updatePlan( + SubscriptionPlan.free(), + await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins(subscription.id), + ) + .updateStatus(SubscriptionStatus.canceled()); + + await this.subscriptionRepository.save(canceledSubscription); + + this.logger.log(`Subscription ${subscriptionId} canceled, downgraded to FREE`); + } + + private async handlePaymentFailed(invoice: Record): Promise { + const customerId = invoice.customer as string; + + 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(), + ); + + await this.subscriptionRepository.save(updatedSubscription); + + 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, + ): LicenseResponseDto { + return { + id: license.id, + userId: license.userId, + userEmail: user?.email || 'Unknown', + userName: user ? `${user.firstName} ${user.lastName}` : 'Unknown User', + userRole: user?.role || 'USER', + status: license.status.value, + assignedAt: license.assignedAt, + revokedAt: license.revokedAt || undefined, + }; + } + + private mapPlanToDto(plan: SubscriptionPlan): PlanDetailsDto { + return { + plan: plan.value as SubscriptionPlanDto, + name: plan.name, + maxLicenses: plan.maxLicenses, + monthlyPriceEur: plan.monthlyPriceEur, + yearlyPriceEur: plan.yearlyPriceEur, + features: [...plan.features], + }; + } +} diff --git a/apps/backend/src/application/subscriptions/subscriptions.module.ts b/apps/backend/src/application/subscriptions/subscriptions.module.ts new file mode 100644 index 0000000..6e71a25 --- /dev/null +++ b/apps/backend/src/application/subscriptions/subscriptions.module.ts @@ -0,0 +1,71 @@ +/** + * Subscriptions Module + * + * Provides subscription and license management endpoints. + */ + +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ConfigModule } from '@nestjs/config'; + +// Controller +import { SubscriptionsController } from '../controllers/subscriptions.controller'; + +// Service +import { SubscriptionService } from '../services/subscription.service'; + +// ORM Entities +import { SubscriptionOrmEntity } from '@infrastructure/persistence/typeorm/entities/subscription.orm-entity'; +import { LicenseOrmEntity } from '@infrastructure/persistence/typeorm/entities/license.orm-entity'; +import { OrganizationOrmEntity } from '@infrastructure/persistence/typeorm/entities/organization.orm-entity'; +import { UserOrmEntity } from '@infrastructure/persistence/typeorm/entities/user.orm-entity'; + +// Repositories +import { TypeOrmSubscriptionRepository } from '@infrastructure/persistence/typeorm/repositories/typeorm-subscription.repository'; +import { TypeOrmLicenseRepository } from '@infrastructure/persistence/typeorm/repositories/typeorm-license.repository'; +import { TypeOrmOrganizationRepository } from '@infrastructure/persistence/typeorm/repositories/typeorm-organization.repository'; +import { TypeOrmUserRepository } from '@infrastructure/persistence/typeorm/repositories/typeorm-user.repository'; + +// Repository tokens +import { SUBSCRIPTION_REPOSITORY } from '@domain/ports/out/subscription.repository'; +import { LICENSE_REPOSITORY } from '@domain/ports/out/license.repository'; +import { ORGANIZATION_REPOSITORY } from '@domain/ports/out/organization.repository'; +import { USER_REPOSITORY } from '@domain/ports/out/user.repository'; + +// Stripe +import { StripeModule } from '@infrastructure/stripe/stripe.module'; + +@Module({ + imports: [ + ConfigModule, + TypeOrmModule.forFeature([ + SubscriptionOrmEntity, + LicenseOrmEntity, + OrganizationOrmEntity, + UserOrmEntity, + ]), + StripeModule, + ], + controllers: [SubscriptionsController], + providers: [ + SubscriptionService, + { + provide: SUBSCRIPTION_REPOSITORY, + useClass: TypeOrmSubscriptionRepository, + }, + { + provide: LICENSE_REPOSITORY, + useClass: TypeOrmLicenseRepository, + }, + { + provide: ORGANIZATION_REPOSITORY, + useClass: TypeOrmOrganizationRepository, + }, + { + provide: USER_REPOSITORY, + useClass: TypeOrmUserRepository, + }, + ], + exports: [SubscriptionService, SUBSCRIPTION_REPOSITORY, LICENSE_REPOSITORY], +}) +export class SubscriptionsModule {} diff --git a/apps/backend/src/application/users/users.module.ts b/apps/backend/src/application/users/users.module.ts index da76372..a5f714c 100644 --- a/apps/backend/src/application/users/users.module.ts +++ b/apps/backend/src/application/users/users.module.ts @@ -6,10 +6,12 @@ import { UsersController } from '../controllers/users.controller'; 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'; @Module({ imports: [ - TypeOrmModule.forFeature([UserOrmEntity]), // 👈 Add this line + TypeOrmModule.forFeature([UserOrmEntity]), + SubscriptionsModule, ], controllers: [UsersController], providers: [ diff --git a/apps/backend/src/domain/entities/csv-booking.entity.ts b/apps/backend/src/domain/entities/csv-booking.entity.ts index 7d92ab0..30f3842 100644 --- a/apps/backend/src/domain/entities/csv-booking.entity.ts +++ b/apps/backend/src/domain/entities/csv-booking.entity.ts @@ -332,4 +332,64 @@ export class CsvBooking { toString(): string { return this.getSummary(); } + + /** + * Create a CsvBooking from persisted data (skips document validation) + * + * Use this when loading from database where bookings might have been created + * before document requirement was enforced, or documents were lost. + */ + static fromPersistence( + id: string, + userId: string, + organizationId: string, + carrierName: string, + carrierEmail: string, + origin: PortCode, + destination: PortCode, + volumeCBM: number, + weightKG: number, + palletCount: number, + priceUSD: number, + priceEUR: number, + primaryCurrency: string, + transitDays: number, + containerType: string, + status: CsvBookingStatus, + documents: CsvBookingDocument[], + confirmationToken: string, + requestedAt: Date, + respondedAt?: Date, + notes?: string, + rejectionReason?: string + ): CsvBooking { + // Create instance without calling constructor validation + const booking = Object.create(CsvBooking.prototype); + + // Assign all properties directly + booking.id = id; + booking.userId = userId; + booking.organizationId = organizationId; + booking.carrierName = carrierName; + booking.carrierEmail = carrierEmail; + booking.origin = origin; + booking.destination = destination; + booking.volumeCBM = volumeCBM; + booking.weightKG = weightKG; + booking.palletCount = palletCount; + booking.priceUSD = priceUSD; + booking.priceEUR = priceEUR; + booking.primaryCurrency = primaryCurrency; + booking.transitDays = transitDays; + booking.containerType = containerType; + booking.status = status; + booking.documents = documents || []; + booking.confirmationToken = confirmationToken; + booking.requestedAt = requestedAt; + booking.respondedAt = respondedAt; + booking.notes = notes; + booking.rejectionReason = rejectionReason; + + return booking; + } } diff --git a/apps/backend/src/domain/entities/index.ts b/apps/backend/src/domain/entities/index.ts index 862b4a7..409cffc 100644 --- a/apps/backend/src/domain/entities/index.ts +++ b/apps/backend/src/domain/entities/index.ts @@ -11,3 +11,5 @@ export * from './port.entity'; export * from './rate-quote.entity'; export * from './container.entity'; export * from './booking.entity'; +export * from './subscription.entity'; +export * from './license.entity'; diff --git a/apps/backend/src/domain/entities/license.entity.spec.ts b/apps/backend/src/domain/entities/license.entity.spec.ts new file mode 100644 index 0000000..d9bf155 --- /dev/null +++ b/apps/backend/src/domain/entities/license.entity.spec.ts @@ -0,0 +1,270 @@ +/** + * License Entity Tests + * + * Unit tests for the License domain entity + */ + +import { License } from './license.entity'; + +describe('License Entity', () => { + const createValidLicense = () => { + return License.create({ + id: 'license-123', + subscriptionId: 'sub-123', + userId: 'user-123', + }); + }; + + describe('create', () => { + it('should create a license with valid data', () => { + const license = createValidLicense(); + + expect(license.id).toBe('license-123'); + expect(license.subscriptionId).toBe('sub-123'); + expect(license.userId).toBe('user-123'); + expect(license.status.value).toBe('ACTIVE'); + expect(license.assignedAt).toBeInstanceOf(Date); + expect(license.revokedAt).toBeNull(); + }); + + it('should create a license with different user', () => { + const license = License.create({ + id: 'license-456', + subscriptionId: 'sub-123', + userId: 'user-456', + }); + + expect(license.userId).toBe('user-456'); + }); + }); + + describe('fromPersistence', () => { + it('should reconstitute an active license from persistence data', () => { + const assignedAt = new Date('2024-01-15'); + const license = License.fromPersistence({ + id: 'license-123', + subscriptionId: 'sub-123', + userId: 'user-123', + status: 'ACTIVE', + assignedAt, + revokedAt: null, + }); + + expect(license.id).toBe('license-123'); + expect(license.status.value).toBe('ACTIVE'); + expect(license.assignedAt).toEqual(assignedAt); + expect(license.revokedAt).toBeNull(); + }); + + it('should reconstitute a revoked license from persistence data', () => { + const revokedAt = new Date('2024-02-01'); + const license = License.fromPersistence({ + id: 'license-123', + subscriptionId: 'sub-123', + userId: 'user-123', + status: 'REVOKED', + assignedAt: new Date('2024-01-15'), + revokedAt, + }); + + expect(license.status.value).toBe('REVOKED'); + expect(license.revokedAt).toEqual(revokedAt); + }); + }); + + describe('isActive', () => { + it('should return true for active license', () => { + const license = createValidLicense(); + expect(license.isActive()).toBe(true); + }); + + it('should return false for revoked license', () => { + const license = License.fromPersistence({ + id: 'license-123', + subscriptionId: 'sub-123', + userId: 'user-123', + status: 'REVOKED', + assignedAt: new Date('2024-01-15'), + revokedAt: new Date('2024-02-01'), + }); + + expect(license.isActive()).toBe(false); + }); + }); + + describe('isRevoked', () => { + it('should return false for active license', () => { + const license = createValidLicense(); + expect(license.isRevoked()).toBe(false); + }); + + it('should return true for revoked license', () => { + const license = License.fromPersistence({ + id: 'license-123', + subscriptionId: 'sub-123', + userId: 'user-123', + status: 'REVOKED', + assignedAt: new Date('2024-01-15'), + revokedAt: new Date('2024-02-01'), + }); + + expect(license.isRevoked()).toBe(true); + }); + }); + + describe('revoke', () => { + it('should revoke an active license', () => { + const license = createValidLicense(); + const revoked = license.revoke(); + + expect(revoked.status.value).toBe('REVOKED'); + expect(revoked.revokedAt).toBeInstanceOf(Date); + expect(revoked.isActive()).toBe(false); + }); + + it('should throw when revoking an already revoked license', () => { + const license = License.fromPersistence({ + id: 'license-123', + subscriptionId: 'sub-123', + userId: 'user-123', + status: 'REVOKED', + assignedAt: new Date('2024-01-15'), + revokedAt: new Date('2024-02-01'), + }); + + expect(() => license.revoke()).toThrow('License is already revoked'); + }); + + it('should preserve original data when revoking', () => { + const license = License.fromPersistence({ + id: 'license-123', + subscriptionId: 'sub-456', + userId: 'user-789', + status: 'ACTIVE', + assignedAt: new Date('2024-01-15'), + revokedAt: null, + }); + + const revoked = license.revoke(); + + expect(revoked.id).toBe('license-123'); + expect(revoked.subscriptionId).toBe('sub-456'); + expect(revoked.userId).toBe('user-789'); + expect(revoked.assignedAt).toEqual(new Date('2024-01-15')); + }); + }); + + describe('reactivate', () => { + it('should reactivate a revoked license', () => { + const license = License.fromPersistence({ + id: 'license-123', + subscriptionId: 'sub-123', + userId: 'user-123', + status: 'REVOKED', + assignedAt: new Date('2024-01-15'), + revokedAt: new Date('2024-02-01'), + }); + + const reactivated = license.reactivate(); + + expect(reactivated.status.value).toBe('ACTIVE'); + expect(reactivated.revokedAt).toBeNull(); + }); + + it('should throw when reactivating an active license', () => { + const license = createValidLicense(); + + expect(() => license.reactivate()).toThrow('License is already active'); + }); + }); + + describe('getActiveDuration', () => { + it('should calculate duration for active license', () => { + const assignedAt = new Date(); + assignedAt.setHours(assignedAt.getHours() - 1); // 1 hour ago + + const license = License.fromPersistence({ + id: 'license-123', + subscriptionId: 'sub-123', + userId: 'user-123', + status: 'ACTIVE', + assignedAt, + revokedAt: null, + }); + + const duration = license.getActiveDuration(); + // Should be approximately 1 hour in milliseconds (allow some variance) + expect(duration).toBeGreaterThan(3600000 - 1000); + expect(duration).toBeLessThan(3600000 + 1000); + }); + + it('should calculate duration for revoked license', () => { + const assignedAt = new Date('2024-01-15T10:00:00Z'); + const revokedAt = new Date('2024-01-15T12:00:00Z'); // 2 hours later + + const license = License.fromPersistence({ + id: 'license-123', + subscriptionId: 'sub-123', + userId: 'user-123', + status: 'REVOKED', + assignedAt, + revokedAt, + }); + + const duration = license.getActiveDuration(); + expect(duration).toBe(2 * 60 * 60 * 1000); // 2 hours in ms + }); + }); + + describe('toObject', () => { + it('should convert to plain object for persistence', () => { + const license = createValidLicense(); + const obj = license.toObject(); + + expect(obj.id).toBe('license-123'); + expect(obj.subscriptionId).toBe('sub-123'); + expect(obj.userId).toBe('user-123'); + expect(obj.status).toBe('ACTIVE'); + expect(obj.assignedAt).toBeInstanceOf(Date); + expect(obj.revokedAt).toBeNull(); + }); + + it('should include revokedAt for revoked license', () => { + const revokedAt = new Date('2024-02-01'); + const license = License.fromPersistence({ + id: 'license-123', + subscriptionId: 'sub-123', + userId: 'user-123', + status: 'REVOKED', + assignedAt: new Date('2024-01-15'), + revokedAt, + }); + + const obj = license.toObject(); + expect(obj.status).toBe('REVOKED'); + expect(obj.revokedAt).toEqual(revokedAt); + }); + }); + + describe('property accessors', () => { + it('should correctly expose all properties', () => { + const assignedAt = new Date('2024-01-15'); + + const license = License.fromPersistence({ + id: 'license-123', + subscriptionId: 'sub-456', + userId: 'user-789', + status: 'ACTIVE', + assignedAt, + revokedAt: null, + }); + + expect(license.id).toBe('license-123'); + expect(license.subscriptionId).toBe('sub-456'); + expect(license.userId).toBe('user-789'); + expect(license.status.value).toBe('ACTIVE'); + expect(license.assignedAt).toEqual(assignedAt); + expect(license.revokedAt).toBeNull(); + }); + }); +}); diff --git a/apps/backend/src/domain/entities/license.entity.ts b/apps/backend/src/domain/entities/license.entity.ts new file mode 100644 index 0000000..75da6b7 --- /dev/null +++ b/apps/backend/src/domain/entities/license.entity.ts @@ -0,0 +1,167 @@ +/** + * License Entity + * + * Represents a user license within a subscription. + * Each active user in an organization consumes one license. + */ + +import { + LicenseStatus, + LicenseStatusType, +} from '../value-objects/license-status.vo'; + +export interface LicenseProps { + readonly id: string; + readonly subscriptionId: string; + readonly userId: string; + readonly status: LicenseStatus; + readonly assignedAt: Date; + readonly revokedAt: Date | null; +} + +export class License { + private readonly props: LicenseProps; + + private constructor(props: LicenseProps) { + this.props = props; + } + + /** + * Create a new license for a user + */ + static create(props: { + id: string; + subscriptionId: string; + userId: string; + }): License { + return new License({ + id: props.id, + subscriptionId: props.subscriptionId, + userId: props.userId, + status: LicenseStatus.active(), + assignedAt: new Date(), + revokedAt: null, + }); + } + + /** + * Reconstitute from persistence + */ + static fromPersistence(props: { + id: string; + subscriptionId: string; + userId: string; + status: LicenseStatusType; + assignedAt: Date; + revokedAt: Date | null; + }): License { + return new License({ + id: props.id, + subscriptionId: props.subscriptionId, + userId: props.userId, + status: LicenseStatus.create(props.status), + assignedAt: props.assignedAt, + revokedAt: props.revokedAt, + }); + } + + // Getters + get id(): string { + return this.props.id; + } + + get subscriptionId(): string { + return this.props.subscriptionId; + } + + get userId(): string { + return this.props.userId; + } + + get status(): LicenseStatus { + return this.props.status; + } + + get assignedAt(): Date { + return this.props.assignedAt; + } + + get revokedAt(): Date | null { + return this.props.revokedAt; + } + + // Business logic + + /** + * Check if the license is currently active + */ + isActive(): boolean { + return this.props.status.isActive(); + } + + /** + * Check if the license has been revoked + */ + isRevoked(): boolean { + return this.props.status.isRevoked(); + } + + /** + * Revoke this license + */ + revoke(): License { + if (this.isRevoked()) { + throw new Error('License is already revoked'); + } + + return new License({ + ...this.props, + status: LicenseStatus.revoked(), + revokedAt: new Date(), + }); + } + + /** + * Reactivate a revoked license + */ + reactivate(): License { + if (this.isActive()) { + throw new Error('License is already active'); + } + + return new License({ + ...this.props, + status: LicenseStatus.active(), + revokedAt: null, + }); + } + + /** + * Get the duration the license was/is active + */ + getActiveDuration(): number { + const endTime = this.props.revokedAt ?? new Date(); + return endTime.getTime() - this.props.assignedAt.getTime(); + } + + /** + * Convert to plain object for persistence + */ + toObject(): { + id: string; + subscriptionId: string; + userId: string; + status: LicenseStatusType; + assignedAt: Date; + revokedAt: Date | null; + } { + return { + id: this.props.id, + subscriptionId: this.props.subscriptionId, + userId: this.props.userId, + status: this.props.status.value, + assignedAt: this.props.assignedAt, + revokedAt: this.props.revokedAt, + }; + } +} diff --git a/apps/backend/src/domain/entities/subscription.entity.spec.ts b/apps/backend/src/domain/entities/subscription.entity.spec.ts new file mode 100644 index 0000000..9b554de --- /dev/null +++ b/apps/backend/src/domain/entities/subscription.entity.spec.ts @@ -0,0 +1,405 @@ +/** + * Subscription Entity Tests + * + * Unit tests for the Subscription domain entity + */ + +import { Subscription } from './subscription.entity'; +import { SubscriptionPlan } from '../value-objects/subscription-plan.vo'; +import { SubscriptionStatus } from '../value-objects/subscription-status.vo'; +import { + InvalidSubscriptionDowngradeException, + SubscriptionNotActiveException, +} from '../exceptions/subscription.exceptions'; + +describe('Subscription Entity', () => { + const createValidSubscription = () => { + return Subscription.create({ + id: 'sub-123', + organizationId: 'org-123', + }); + }; + + describe('create', () => { + it('should create a subscription with default FREE plan', () => { + const subscription = createValidSubscription(); + + expect(subscription.id).toBe('sub-123'); + expect(subscription.organizationId).toBe('org-123'); + expect(subscription.plan.value).toBe('FREE'); + expect(subscription.status.value).toBe('ACTIVE'); + expect(subscription.cancelAtPeriodEnd).toBe(false); + }); + + it('should create a subscription with custom plan', () => { + const subscription = Subscription.create({ + id: 'sub-123', + organizationId: 'org-123', + plan: SubscriptionPlan.starter(), + }); + + expect(subscription.plan.value).toBe('STARTER'); + }); + + it('should create a subscription with Stripe IDs', () => { + const subscription = Subscription.create({ + id: 'sub-123', + organizationId: 'org-123', + stripeCustomerId: 'cus_123', + stripeSubscriptionId: 'sub_stripe_123', + }); + + expect(subscription.stripeCustomerId).toBe('cus_123'); + expect(subscription.stripeSubscriptionId).toBe('sub_stripe_123'); + }); + }); + + describe('fromPersistence', () => { + it('should reconstitute a subscription from persistence data', () => { + const subscription = Subscription.fromPersistence({ + id: 'sub-123', + organizationId: 'org-123', + plan: 'PRO', + status: 'ACTIVE', + stripeCustomerId: 'cus_123', + stripeSubscriptionId: 'sub_stripe_123', + currentPeriodStart: new Date('2024-01-01'), + currentPeriodEnd: new Date('2024-02-01'), + cancelAtPeriodEnd: true, + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-01-15'), + }); + + expect(subscription.id).toBe('sub-123'); + expect(subscription.plan.value).toBe('PRO'); + expect(subscription.status.value).toBe('ACTIVE'); + expect(subscription.cancelAtPeriodEnd).toBe(true); + }); + }); + + describe('maxLicenses', () => { + it('should return correct limits for FREE plan', () => { + const subscription = createValidSubscription(); + expect(subscription.maxLicenses).toBe(2); + }); + + it('should return correct limits for STARTER plan', () => { + const subscription = Subscription.create({ + id: 'sub-123', + organizationId: 'org-123', + plan: SubscriptionPlan.starter(), + }); + expect(subscription.maxLicenses).toBe(5); + }); + + it('should return correct limits for PRO plan', () => { + const subscription = Subscription.create({ + id: 'sub-123', + organizationId: 'org-123', + plan: SubscriptionPlan.pro(), + }); + expect(subscription.maxLicenses).toBe(20); + }); + + it('should return -1 for ENTERPRISE plan (unlimited)', () => { + const subscription = Subscription.create({ + id: 'sub-123', + organizationId: 'org-123', + plan: SubscriptionPlan.enterprise(), + }); + expect(subscription.maxLicenses).toBe(-1); + }); + }); + + describe('isUnlimited', () => { + it('should return false for FREE plan', () => { + const subscription = createValidSubscription(); + expect(subscription.isUnlimited()).toBe(false); + }); + + it('should return true for ENTERPRISE plan', () => { + const subscription = Subscription.create({ + id: 'sub-123', + organizationId: 'org-123', + plan: SubscriptionPlan.enterprise(), + }); + expect(subscription.isUnlimited()).toBe(true); + }); + }); + + describe('isActive', () => { + it('should return true for ACTIVE status', () => { + const subscription = createValidSubscription(); + expect(subscription.isActive()).toBe(true); + }); + + it('should return true for TRIALING status', () => { + const subscription = Subscription.fromPersistence({ + id: 'sub-123', + organizationId: 'org-123', + plan: 'FREE', + status: 'TRIALING', + stripeCustomerId: null, + stripeSubscriptionId: null, + currentPeriodStart: null, + currentPeriodEnd: null, + cancelAtPeriodEnd: false, + createdAt: new Date(), + updatedAt: new Date(), + }); + expect(subscription.isActive()).toBe(true); + }); + + it('should return false for CANCELED status', () => { + const subscription = Subscription.fromPersistence({ + id: 'sub-123', + organizationId: 'org-123', + plan: 'FREE', + status: 'CANCELED', + stripeCustomerId: null, + stripeSubscriptionId: null, + currentPeriodStart: null, + currentPeriodEnd: null, + cancelAtPeriodEnd: false, + createdAt: new Date(), + updatedAt: new Date(), + }); + expect(subscription.isActive()).toBe(false); + }); + }); + + describe('canAllocateLicenses', () => { + it('should return true when licenses are available', () => { + const subscription = createValidSubscription(); + expect(subscription.canAllocateLicenses(0, 1)).toBe(true); + expect(subscription.canAllocateLicenses(1, 1)).toBe(true); + }); + + it('should return false when no licenses available', () => { + const subscription = createValidSubscription(); + expect(subscription.canAllocateLicenses(2, 1)).toBe(false); // FREE has 2 licenses + }); + + it('should always return true for ENTERPRISE plan', () => { + const subscription = Subscription.create({ + id: 'sub-123', + organizationId: 'org-123', + plan: SubscriptionPlan.enterprise(), + }); + expect(subscription.canAllocateLicenses(1000, 100)).toBe(true); + }); + + it('should return false when subscription is not active', () => { + const subscription = Subscription.fromPersistence({ + id: 'sub-123', + organizationId: 'org-123', + plan: 'FREE', + status: 'CANCELED', + stripeCustomerId: null, + stripeSubscriptionId: null, + currentPeriodStart: null, + currentPeriodEnd: null, + cancelAtPeriodEnd: false, + createdAt: new Date(), + updatedAt: new Date(), + }); + expect(subscription.canAllocateLicenses(0, 1)).toBe(false); + }); + }); + + describe('canUpgradeTo', () => { + it('should allow upgrade from FREE to STARTER', () => { + const subscription = createValidSubscription(); + expect(subscription.canUpgradeTo(SubscriptionPlan.starter())).toBe(true); + }); + + it('should allow upgrade from FREE to PRO', () => { + const subscription = createValidSubscription(); + expect(subscription.canUpgradeTo(SubscriptionPlan.pro())).toBe(true); + }); + + it('should not allow downgrade via canUpgradeTo', () => { + const subscription = Subscription.create({ + id: 'sub-123', + organizationId: 'org-123', + plan: SubscriptionPlan.starter(), + }); + expect(subscription.canUpgradeTo(SubscriptionPlan.free())).toBe(false); + }); + }); + + describe('canDowngradeTo', () => { + it('should allow downgrade when user count fits', () => { + const subscription = Subscription.create({ + id: 'sub-123', + organizationId: 'org-123', + plan: SubscriptionPlan.starter(), + }); + expect(subscription.canDowngradeTo(SubscriptionPlan.free(), 1)).toBe(true); + }); + + it('should prevent downgrade when user count exceeds new plan', () => { + const subscription = Subscription.create({ + id: 'sub-123', + organizationId: 'org-123', + plan: SubscriptionPlan.starter(), + }); + expect(subscription.canDowngradeTo(SubscriptionPlan.free(), 5)).toBe(false); + }); + }); + + describe('updatePlan', () => { + it('should update to new plan when valid', () => { + const subscription = createValidSubscription(); + const updated = subscription.updatePlan(SubscriptionPlan.starter(), 1); + + expect(updated.plan.value).toBe('STARTER'); + }); + + it('should throw when subscription is not active', () => { + const subscription = Subscription.fromPersistence({ + id: 'sub-123', + organizationId: 'org-123', + plan: 'FREE', + status: 'CANCELED', + stripeCustomerId: null, + stripeSubscriptionId: null, + currentPeriodStart: null, + currentPeriodEnd: null, + cancelAtPeriodEnd: false, + createdAt: new Date(), + updatedAt: new Date(), + }); + + expect(() => subscription.updatePlan(SubscriptionPlan.starter(), 0)).toThrow( + SubscriptionNotActiveException, + ); + }); + + it('should throw when downgrading with too many users', () => { + const subscription = Subscription.create({ + id: 'sub-123', + organizationId: 'org-123', + plan: SubscriptionPlan.pro(), + }); + + expect(() => subscription.updatePlan(SubscriptionPlan.starter(), 10)).toThrow( + InvalidSubscriptionDowngradeException, + ); + }); + }); + + describe('updateStatus', () => { + it('should update subscription status', () => { + const subscription = createValidSubscription(); + const updated = subscription.updateStatus(SubscriptionStatus.pastDue()); + + expect(updated.status.value).toBe('PAST_DUE'); + }); + }); + + describe('updateStripeCustomerId', () => { + it('should update Stripe customer ID', () => { + const subscription = createValidSubscription(); + const updated = subscription.updateStripeCustomerId('cus_new_123'); + + expect(updated.stripeCustomerId).toBe('cus_new_123'); + }); + }); + + describe('updateStripeSubscription', () => { + it('should update Stripe subscription details', () => { + const subscription = createValidSubscription(); + const periodStart = new Date('2024-02-01'); + const periodEnd = new Date('2024-03-01'); + + const updated = subscription.updateStripeSubscription({ + stripeSubscriptionId: 'sub_new_123', + currentPeriodStart: periodStart, + currentPeriodEnd: periodEnd, + cancelAtPeriodEnd: true, + }); + + expect(updated.stripeSubscriptionId).toBe('sub_new_123'); + expect(updated.currentPeriodStart).toEqual(periodStart); + expect(updated.currentPeriodEnd).toEqual(periodEnd); + expect(updated.cancelAtPeriodEnd).toBe(true); + }); + }); + + describe('scheduleCancellation', () => { + it('should mark subscription for cancellation', () => { + const subscription = createValidSubscription(); + const updated = subscription.scheduleCancellation(); + + expect(updated.cancelAtPeriodEnd).toBe(true); + }); + }); + + describe('unscheduleCancellation', () => { + it('should unmark subscription for cancellation', () => { + const subscription = Subscription.fromPersistence({ + id: 'sub-123', + organizationId: 'org-123', + plan: 'STARTER', + status: 'ACTIVE', + stripeCustomerId: 'cus_123', + stripeSubscriptionId: 'sub_123', + currentPeriodStart: new Date(), + currentPeriodEnd: new Date(), + cancelAtPeriodEnd: true, + createdAt: new Date(), + updatedAt: new Date(), + }); + + const updated = subscription.unscheduleCancellation(); + expect(updated.cancelAtPeriodEnd).toBe(false); + }); + }); + + describe('cancel', () => { + it('should cancel the subscription immediately', () => { + const subscription = createValidSubscription(); + const updated = subscription.cancel(); + + expect(updated.status.value).toBe('CANCELED'); + expect(updated.cancelAtPeriodEnd).toBe(false); + }); + }); + + describe('isFree and isPaid', () => { + it('should return true for isFree when FREE plan', () => { + const subscription = createValidSubscription(); + expect(subscription.isFree()).toBe(true); + expect(subscription.isPaid()).toBe(false); + }); + + it('should return true for isPaid when STARTER plan', () => { + const subscription = Subscription.create({ + id: 'sub-123', + organizationId: 'org-123', + plan: SubscriptionPlan.starter(), + }); + expect(subscription.isFree()).toBe(false); + expect(subscription.isPaid()).toBe(true); + }); + }); + + describe('toObject', () => { + it('should convert to plain object for persistence', () => { + const subscription = Subscription.create({ + id: 'sub-123', + organizationId: 'org-123', + stripeCustomerId: 'cus_123', + }); + + const obj = subscription.toObject(); + + expect(obj.id).toBe('sub-123'); + expect(obj.organizationId).toBe('org-123'); + expect(obj.plan).toBe('FREE'); + expect(obj.status).toBe('ACTIVE'); + expect(obj.stripeCustomerId).toBe('cus_123'); + }); + }); +}); diff --git a/apps/backend/src/domain/entities/subscription.entity.ts b/apps/backend/src/domain/entities/subscription.entity.ts new file mode 100644 index 0000000..572af04 --- /dev/null +++ b/apps/backend/src/domain/entities/subscription.entity.ts @@ -0,0 +1,355 @@ +/** + * Subscription Entity + * + * Represents an organization's subscription, including their plan, + * Stripe integration, and billing period information. + */ + +import { + SubscriptionPlan, + SubscriptionPlanType, +} from '../value-objects/subscription-plan.vo'; +import { + SubscriptionStatus, + SubscriptionStatusType, +} from '../value-objects/subscription-status.vo'; +import { + InvalidSubscriptionDowngradeException, + SubscriptionNotActiveException, +} from '../exceptions/subscription.exceptions'; + +export interface SubscriptionProps { + readonly id: string; + readonly organizationId: string; + readonly plan: SubscriptionPlan; + readonly status: SubscriptionStatus; + readonly stripeCustomerId: string | null; + readonly stripeSubscriptionId: string | null; + readonly currentPeriodStart: Date | null; + readonly currentPeriodEnd: Date | null; + readonly cancelAtPeriodEnd: boolean; + readonly createdAt: Date; + readonly updatedAt: Date; +} + +export class Subscription { + private readonly props: SubscriptionProps; + + private constructor(props: SubscriptionProps) { + this.props = props; + } + + /** + * Create a new subscription (defaults to FREE plan) + */ + static create(props: { + id: string; + organizationId: string; + plan?: SubscriptionPlan; + stripeCustomerId?: string | null; + stripeSubscriptionId?: string | null; + }): Subscription { + const now = new Date(); + return new Subscription({ + id: props.id, + organizationId: props.organizationId, + plan: props.plan ?? SubscriptionPlan.free(), + status: SubscriptionStatus.active(), + stripeCustomerId: props.stripeCustomerId ?? null, + stripeSubscriptionId: props.stripeSubscriptionId ?? null, + currentPeriodStart: null, + currentPeriodEnd: null, + cancelAtPeriodEnd: false, + createdAt: now, + updatedAt: now, + }); + } + + /** + * Reconstitute from persistence + */ + static fromPersistence(props: { + id: string; + organizationId: string; + plan: SubscriptionPlanType; + status: SubscriptionStatusType; + stripeCustomerId: string | null; + stripeSubscriptionId: string | null; + currentPeriodStart: Date | null; + currentPeriodEnd: Date | null; + cancelAtPeriodEnd: boolean; + createdAt: Date; + updatedAt: Date; + }): Subscription { + return new Subscription({ + id: props.id, + organizationId: props.organizationId, + plan: SubscriptionPlan.create(props.plan), + status: SubscriptionStatus.create(props.status), + stripeCustomerId: props.stripeCustomerId, + stripeSubscriptionId: props.stripeSubscriptionId, + currentPeriodStart: props.currentPeriodStart, + currentPeriodEnd: props.currentPeriodEnd, + cancelAtPeriodEnd: props.cancelAtPeriodEnd, + createdAt: props.createdAt, + updatedAt: props.updatedAt, + }); + } + + // Getters + get id(): string { + return this.props.id; + } + + get organizationId(): string { + return this.props.organizationId; + } + + get plan(): SubscriptionPlan { + return this.props.plan; + } + + get status(): SubscriptionStatus { + return this.props.status; + } + + get stripeCustomerId(): string | null { + return this.props.stripeCustomerId; + } + + get stripeSubscriptionId(): string | null { + return this.props.stripeSubscriptionId; + } + + get currentPeriodStart(): Date | null { + return this.props.currentPeriodStart; + } + + get currentPeriodEnd(): Date | null { + return this.props.currentPeriodEnd; + } + + get cancelAtPeriodEnd(): boolean { + return this.props.cancelAtPeriodEnd; + } + + get createdAt(): Date { + return this.props.createdAt; + } + + get updatedAt(): Date { + return this.props.updatedAt; + } + + // Business logic + + /** + * Get the maximum number of licenses allowed by this subscription + */ + get maxLicenses(): number { + return this.props.plan.maxLicenses; + } + + /** + * Check if the subscription has unlimited licenses + */ + isUnlimited(): boolean { + return this.props.plan.isUnlimited(); + } + + /** + * Check if the subscription is active and allows access + */ + isActive(): boolean { + return this.props.status.allowsAccess(); + } + + /** + * Check if the subscription is in good standing + */ + isInGoodStanding(): boolean { + return this.props.status.isInGoodStanding(); + } + + /** + * Check if the subscription requires user action + */ + requiresAction(): boolean { + return this.props.status.requiresAction(); + } + + /** + * Check if this is a free subscription + */ + isFree(): boolean { + return this.props.plan.isFree(); + } + + /** + * Check if this is a paid subscription + */ + isPaid(): boolean { + return this.props.plan.isPaid(); + } + + /** + * Check if the subscription is scheduled to be canceled + */ + isScheduledForCancellation(): boolean { + return this.props.cancelAtPeriodEnd; + } + + /** + * Check if a given number of licenses can be allocated + */ + canAllocateLicenses(currentCount: number, additionalCount: number = 1): boolean { + if (!this.isActive()) return false; + if (this.isUnlimited()) return true; + return currentCount + additionalCount <= this.maxLicenses; + } + + /** + * Check if upgrade to target plan is possible + */ + canUpgradeTo(targetPlan: SubscriptionPlan): boolean { + return this.props.plan.canUpgradeTo(targetPlan); + } + + /** + * Check if downgrade to target plan is possible given current user count + */ + canDowngradeTo(targetPlan: SubscriptionPlan, currentUserCount: number): boolean { + return this.props.plan.canDowngradeTo(targetPlan, currentUserCount); + } + + /** + * Update the subscription plan + */ + updatePlan(newPlan: SubscriptionPlan, currentUserCount: number): Subscription { + if (!this.isActive()) { + throw new SubscriptionNotActiveException(this.props.id, this.props.status.value); + } + + // Check if downgrade is valid + if (!newPlan.canAccommodateUsers(currentUserCount)) { + throw new InvalidSubscriptionDowngradeException( + this.props.plan.value, + newPlan.value, + currentUserCount, + newPlan.maxLicenses, + ); + } + + return new Subscription({ + ...this.props, + plan: newPlan, + updatedAt: new Date(), + }); + } + + /** + * Update subscription status + */ + updateStatus(newStatus: SubscriptionStatus): Subscription { + return new Subscription({ + ...this.props, + status: newStatus, + updatedAt: new Date(), + }); + } + + /** + * Update Stripe customer ID + */ + updateStripeCustomerId(stripeCustomerId: string): Subscription { + return new Subscription({ + ...this.props, + stripeCustomerId, + updatedAt: new Date(), + }); + } + + /** + * Update Stripe subscription details + */ + updateStripeSubscription(params: { + stripeSubscriptionId: string; + currentPeriodStart: Date; + currentPeriodEnd: Date; + cancelAtPeriodEnd?: boolean; + }): Subscription { + return new Subscription({ + ...this.props, + stripeSubscriptionId: params.stripeSubscriptionId, + currentPeriodStart: params.currentPeriodStart, + currentPeriodEnd: params.currentPeriodEnd, + cancelAtPeriodEnd: params.cancelAtPeriodEnd ?? this.props.cancelAtPeriodEnd, + updatedAt: new Date(), + }); + } + + /** + * Mark subscription as scheduled for cancellation at period end + */ + scheduleCancellation(): Subscription { + return new Subscription({ + ...this.props, + cancelAtPeriodEnd: true, + updatedAt: new Date(), + }); + } + + /** + * Unschedule cancellation + */ + unscheduleCancellation(): Subscription { + return new Subscription({ + ...this.props, + cancelAtPeriodEnd: false, + updatedAt: new Date(), + }); + } + + /** + * Cancel the subscription immediately + */ + cancel(): Subscription { + return new Subscription({ + ...this.props, + status: SubscriptionStatus.canceled(), + cancelAtPeriodEnd: false, + updatedAt: new Date(), + }); + } + + /** + * Convert to plain object for persistence + */ + toObject(): { + id: string; + organizationId: string; + plan: SubscriptionPlanType; + status: SubscriptionStatusType; + stripeCustomerId: string | null; + stripeSubscriptionId: string | null; + currentPeriodStart: Date | null; + currentPeriodEnd: Date | null; + cancelAtPeriodEnd: boolean; + createdAt: Date; + updatedAt: Date; + } { + return { + id: this.props.id, + organizationId: this.props.organizationId, + plan: this.props.plan.value, + status: this.props.status.value, + stripeCustomerId: this.props.stripeCustomerId, + stripeSubscriptionId: this.props.stripeSubscriptionId, + currentPeriodStart: this.props.currentPeriodStart, + currentPeriodEnd: this.props.currentPeriodEnd, + cancelAtPeriodEnd: this.props.cancelAtPeriodEnd, + createdAt: this.props.createdAt, + updatedAt: this.props.updatedAt, + }; + } +} diff --git a/apps/backend/src/domain/exceptions/index.ts b/apps/backend/src/domain/exceptions/index.ts index d8ae549..a35e026 100644 --- a/apps/backend/src/domain/exceptions/index.ts +++ b/apps/backend/src/domain/exceptions/index.ts @@ -10,3 +10,4 @@ export * from './carrier-timeout.exception'; export * from './carrier-unavailable.exception'; export * from './rate-quote-expired.exception'; export * from './port-not-found.exception'; +export * from './subscription.exceptions'; diff --git a/apps/backend/src/domain/exceptions/subscription.exceptions.ts b/apps/backend/src/domain/exceptions/subscription.exceptions.ts new file mode 100644 index 0000000..55cdcbd --- /dev/null +++ b/apps/backend/src/domain/exceptions/subscription.exceptions.ts @@ -0,0 +1,85 @@ +/** + * Subscription Domain Exceptions + */ + +export class NoLicensesAvailableException extends Error { + constructor( + public readonly organizationId: string, + public readonly currentLicenses: number, + public readonly maxLicenses: number, + ) { + super( + `No licenses available for organization ${organizationId}. ` + + `Currently using ${currentLicenses}/${maxLicenses} licenses.`, + ); + this.name = 'NoLicensesAvailableException'; + Object.setPrototypeOf(this, NoLicensesAvailableException.prototype); + } +} + +export class SubscriptionNotFoundException extends Error { + constructor(public readonly identifier: string) { + super(`Subscription not found: ${identifier}`); + this.name = 'SubscriptionNotFoundException'; + Object.setPrototypeOf(this, SubscriptionNotFoundException.prototype); + } +} + +export class LicenseNotFoundException extends Error { + constructor(public readonly identifier: string) { + super(`License not found: ${identifier}`); + this.name = 'LicenseNotFoundException'; + Object.setPrototypeOf(this, LicenseNotFoundException.prototype); + } +} + +export class LicenseAlreadyAssignedException extends Error { + constructor(public readonly userId: string) { + super(`User ${userId} already has an assigned license`); + this.name = 'LicenseAlreadyAssignedException'; + Object.setPrototypeOf(this, LicenseAlreadyAssignedException.prototype); + } +} + +export class InvalidSubscriptionDowngradeException extends Error { + constructor( + public readonly currentPlan: string, + public readonly targetPlan: string, + public readonly currentUsers: number, + public readonly targetMaxLicenses: number, + ) { + super( + `Cannot downgrade from ${currentPlan} to ${targetPlan}. ` + + `Current users (${currentUsers}) exceed target plan limit (${targetMaxLicenses}).`, + ); + this.name = 'InvalidSubscriptionDowngradeException'; + Object.setPrototypeOf(this, InvalidSubscriptionDowngradeException.prototype); + } +} + +export class SubscriptionNotActiveException extends Error { + constructor( + public readonly subscriptionId: string, + public readonly currentStatus: string, + ) { + super( + `Subscription ${subscriptionId} is not active. Current status: ${currentStatus}`, + ); + this.name = 'SubscriptionNotActiveException'; + Object.setPrototypeOf(this, SubscriptionNotActiveException.prototype); + } +} + +export class InvalidSubscriptionStatusTransitionException extends Error { + constructor( + public readonly fromStatus: string, + public readonly toStatus: string, + ) { + super(`Invalid subscription status transition from ${fromStatus} to ${toStatus}`); + this.name = 'InvalidSubscriptionStatusTransitionException'; + Object.setPrototypeOf( + this, + InvalidSubscriptionStatusTransitionException.prototype, + ); + } +} diff --git a/apps/backend/src/domain/ports/out/index.ts b/apps/backend/src/domain/ports/out/index.ts index 2f50b35..9f47d85 100644 --- a/apps/backend/src/domain/ports/out/index.ts +++ b/apps/backend/src/domain/ports/out/index.ts @@ -23,3 +23,6 @@ export * from './pdf.port'; export * from './storage.port'; export * from './carrier-connector.port'; export * from './csv-rate-loader.port'; +export * from './subscription.repository'; +export * from './license.repository'; +export * from './stripe.port'; diff --git a/apps/backend/src/domain/ports/out/license.repository.ts b/apps/backend/src/domain/ports/out/license.repository.ts new file mode 100644 index 0000000..c857d1e --- /dev/null +++ b/apps/backend/src/domain/ports/out/license.repository.ts @@ -0,0 +1,62 @@ +/** + * License Repository Port + * + * Interface for license persistence operations. + */ + +import { License } from '../../entities/license.entity'; + +export const LICENSE_REPOSITORY = 'LICENSE_REPOSITORY'; + +export interface LicenseRepository { + /** + * Save a license (create or update) + */ + save(license: License): Promise; + + /** + * Find a license by its ID + */ + findById(id: string): Promise; + + /** + * Find a license by user ID + */ + findByUserId(userId: string): Promise; + + /** + * Find all licenses for a subscription + */ + findBySubscriptionId(subscriptionId: string): Promise; + + /** + * Find all active licenses for a subscription + */ + findActiveBySubscriptionId(subscriptionId: string): Promise; + + /** + * Count active licenses for a subscription + */ + countActiveBySubscriptionId(subscriptionId: string): Promise; + + /** + * Count active licenses for a subscription, excluding ADMIN users + * ADMIN users have unlimited licenses and don't consume the organization's quota + */ + countActiveBySubscriptionIdExcludingAdmins(subscriptionId: string): Promise; + + /** + * Find all active licenses for a subscription, excluding ADMIN users + */ + findActiveBySubscriptionIdExcludingAdmins(subscriptionId: string): Promise; + + /** + * Delete a license + */ + delete(id: string): Promise; + + /** + * Delete all licenses for a subscription + */ + deleteBySubscriptionId(subscriptionId: string): Promise; +} diff --git a/apps/backend/src/domain/ports/out/stripe.port.ts b/apps/backend/src/domain/ports/out/stripe.port.ts new file mode 100644 index 0000000..564dbfa --- /dev/null +++ b/apps/backend/src/domain/ports/out/stripe.port.ts @@ -0,0 +1,113 @@ +/** + * Stripe Port + * + * Interface for Stripe payment integration. + */ + +import { SubscriptionPlanType } from '../../value-objects/subscription-plan.vo'; + +export const STRIPE_PORT = 'STRIPE_PORT'; + +export interface CreateCheckoutSessionInput { + organizationId: string; + organizationName: string; + email: string; + plan: SubscriptionPlanType; + billingInterval: 'monthly' | 'yearly'; + successUrl: string; + cancelUrl: string; + customerId?: string; +} + +export interface CreateCheckoutSessionOutput { + sessionId: string; + sessionUrl: string; +} + +export interface CreatePortalSessionInput { + customerId: string; + returnUrl: string; +} + +export interface CreatePortalSessionOutput { + sessionUrl: string; +} + +export interface StripeSubscriptionData { + subscriptionId: string; + customerId: string; + status: string; + planId: string; + currentPeriodStart: Date; + currentPeriodEnd: Date; + cancelAtPeriodEnd: boolean; +} + +export interface StripeCheckoutSessionData { + sessionId: string; + customerId: string | null; + subscriptionId: string | null; + status: string; + metadata: Record; +} + +export interface StripeWebhookEvent { + type: string; + data: { + object: Record; + }; +} + +export interface StripePort { + /** + * Create a Stripe Checkout session for subscription purchase + */ + createCheckoutSession( + input: CreateCheckoutSessionInput, + ): Promise; + + /** + * Create a Stripe Customer Portal session for subscription management + */ + createPortalSession( + input: CreatePortalSessionInput, + ): Promise; + + /** + * Retrieve subscription details from Stripe + */ + getSubscription(subscriptionId: string): Promise; + + /** + * Retrieve checkout session details from Stripe + */ + getCheckoutSession(sessionId: string): Promise; + + /** + * Cancel a subscription at period end + */ + cancelSubscriptionAtPeriodEnd(subscriptionId: string): Promise; + + /** + * Cancel a subscription immediately + */ + cancelSubscriptionImmediately(subscriptionId: string): Promise; + + /** + * Resume a canceled subscription + */ + resumeSubscription(subscriptionId: string): Promise; + + /** + * Verify and parse a Stripe webhook event + */ + constructWebhookEvent( + payload: string | Buffer, + signature: string, + ): Promise; + + /** + * Map a Stripe price ID to a subscription plan + */ + mapPriceIdToPlan(priceId: string): SubscriptionPlanType | null; +} diff --git a/apps/backend/src/domain/ports/out/subscription.repository.ts b/apps/backend/src/domain/ports/out/subscription.repository.ts new file mode 100644 index 0000000..2793601 --- /dev/null +++ b/apps/backend/src/domain/ports/out/subscription.repository.ts @@ -0,0 +1,46 @@ +/** + * Subscription Repository Port + * + * Interface for subscription persistence operations. + */ + +import { Subscription } from '../../entities/subscription.entity'; + +export const SUBSCRIPTION_REPOSITORY = 'SUBSCRIPTION_REPOSITORY'; + +export interface SubscriptionRepository { + /** + * Save a subscription (create or update) + */ + save(subscription: Subscription): Promise; + + /** + * Find a subscription by its ID + */ + findById(id: string): Promise; + + /** + * Find a subscription by organization ID + */ + findByOrganizationId(organizationId: string): Promise; + + /** + * Find a subscription by Stripe subscription ID + */ + findByStripeSubscriptionId(stripeSubscriptionId: string): Promise; + + /** + * Find a subscription by Stripe customer ID + */ + findByStripeCustomerId(stripeCustomerId: string): Promise; + + /** + * Find all subscriptions + */ + findAll(): Promise; + + /** + * Delete a subscription + */ + delete(id: string): Promise; +} diff --git a/apps/backend/src/domain/value-objects/index.ts b/apps/backend/src/domain/value-objects/index.ts index 13d1f43..1773663 100644 --- a/apps/backend/src/domain/value-objects/index.ts +++ b/apps/backend/src/domain/value-objects/index.ts @@ -11,3 +11,6 @@ export * from './container-type.vo'; export * from './date-range.vo'; export * from './booking-number.vo'; export * from './booking-status.vo'; +export * from './subscription-plan.vo'; +export * from './subscription-status.vo'; +export * from './license-status.vo'; diff --git a/apps/backend/src/domain/value-objects/license-status.vo.ts b/apps/backend/src/domain/value-objects/license-status.vo.ts new file mode 100644 index 0000000..70707e6 --- /dev/null +++ b/apps/backend/src/domain/value-objects/license-status.vo.ts @@ -0,0 +1,74 @@ +/** + * License Status Value Object + * + * Represents the status of a user license within a subscription. + */ + +export type LicenseStatusType = 'ACTIVE' | 'REVOKED'; + +export class LicenseStatus { + private constructor(private readonly status: LicenseStatusType) {} + + static create(status: LicenseStatusType): LicenseStatus { + if (status !== 'ACTIVE' && status !== 'REVOKED') { + throw new Error(`Invalid license status: ${status}`); + } + return new LicenseStatus(status); + } + + static fromString(value: string): LicenseStatus { + const upperValue = value.toUpperCase() as LicenseStatusType; + if (upperValue !== 'ACTIVE' && upperValue !== 'REVOKED') { + throw new Error(`Invalid license status: ${value}`); + } + return new LicenseStatus(upperValue); + } + + static active(): LicenseStatus { + return new LicenseStatus('ACTIVE'); + } + + static revoked(): LicenseStatus { + return new LicenseStatus('REVOKED'); + } + + get value(): LicenseStatusType { + return this.status; + } + + isActive(): boolean { + return this.status === 'ACTIVE'; + } + + isRevoked(): boolean { + return this.status === 'REVOKED'; + } + + /** + * Revoke this license, returning a new revoked status + */ + revoke(): LicenseStatus { + if (this.status === 'REVOKED') { + throw new Error('License is already revoked'); + } + return LicenseStatus.revoked(); + } + + /** + * Reactivate this license, returning a new active status + */ + reactivate(): LicenseStatus { + if (this.status === 'ACTIVE') { + throw new Error('License is already active'); + } + return LicenseStatus.active(); + } + + equals(other: LicenseStatus): boolean { + return this.status === other.status; + } + + toString(): string { + return this.status; + } +} diff --git a/apps/backend/src/domain/value-objects/subscription-plan.vo.spec.ts b/apps/backend/src/domain/value-objects/subscription-plan.vo.spec.ts new file mode 100644 index 0000000..81564a3 --- /dev/null +++ b/apps/backend/src/domain/value-objects/subscription-plan.vo.spec.ts @@ -0,0 +1,223 @@ +/** + * SubscriptionPlan Value Object Tests + * + * Unit tests for the SubscriptionPlan value object + */ + +import { SubscriptionPlan } from './subscription-plan.vo'; + +describe('SubscriptionPlan Value Object', () => { + describe('static factory methods', () => { + it('should create FREE plan', () => { + const plan = SubscriptionPlan.free(); + expect(plan.value).toBe('FREE'); + }); + + it('should create STARTER plan', () => { + const plan = SubscriptionPlan.starter(); + expect(plan.value).toBe('STARTER'); + }); + + it('should create PRO plan', () => { + const plan = SubscriptionPlan.pro(); + expect(plan.value).toBe('PRO'); + }); + + it('should create ENTERPRISE plan', () => { + const plan = SubscriptionPlan.enterprise(); + expect(plan.value).toBe('ENTERPRISE'); + }); + }); + + describe('create', () => { + it('should create plan from valid type', () => { + const plan = SubscriptionPlan.create('STARTER'); + expect(plan.value).toBe('STARTER'); + }); + + it('should throw for invalid plan type', () => { + expect(() => SubscriptionPlan.create('INVALID' as any)).toThrow('Invalid subscription plan'); + }); + }); + + describe('fromString', () => { + it('should create plan from lowercase string', () => { + const plan = SubscriptionPlan.fromString('starter'); + expect(plan.value).toBe('STARTER'); + }); + + it('should throw for invalid string', () => { + expect(() => SubscriptionPlan.fromString('invalid')).toThrow('Invalid subscription plan'); + }); + }); + + describe('maxLicenses', () => { + it('should return 2 for FREE plan', () => { + const plan = SubscriptionPlan.free(); + expect(plan.maxLicenses).toBe(2); + }); + + it('should return 5 for STARTER plan', () => { + const plan = SubscriptionPlan.starter(); + expect(plan.maxLicenses).toBe(5); + }); + + it('should return 20 for PRO plan', () => { + const plan = SubscriptionPlan.pro(); + expect(plan.maxLicenses).toBe(20); + }); + + it('should return -1 (unlimited) for ENTERPRISE plan', () => { + const plan = SubscriptionPlan.enterprise(); + expect(plan.maxLicenses).toBe(-1); + }); + }); + + describe('isUnlimited', () => { + it('should return false for FREE plan', () => { + expect(SubscriptionPlan.free().isUnlimited()).toBe(false); + }); + + it('should return false for STARTER plan', () => { + expect(SubscriptionPlan.starter().isUnlimited()).toBe(false); + }); + + it('should return false for PRO plan', () => { + expect(SubscriptionPlan.pro().isUnlimited()).toBe(false); + }); + + it('should return true for ENTERPRISE plan', () => { + expect(SubscriptionPlan.enterprise().isUnlimited()).toBe(true); + }); + }); + + describe('isPaid', () => { + it('should return false for FREE plan', () => { + expect(SubscriptionPlan.free().isPaid()).toBe(false); + }); + + it('should return true for STARTER plan', () => { + expect(SubscriptionPlan.starter().isPaid()).toBe(true); + }); + + it('should return true for PRO plan', () => { + expect(SubscriptionPlan.pro().isPaid()).toBe(true); + }); + + it('should return true for ENTERPRISE plan', () => { + expect(SubscriptionPlan.enterprise().isPaid()).toBe(true); + }); + }); + + describe('isFree', () => { + it('should return true for FREE plan', () => { + expect(SubscriptionPlan.free().isFree()).toBe(true); + }); + + it('should return false for STARTER plan', () => { + expect(SubscriptionPlan.starter().isFree()).toBe(false); + }); + }); + + describe('canAccommodateUsers', () => { + it('should return true for FREE plan with 2 users', () => { + expect(SubscriptionPlan.free().canAccommodateUsers(2)).toBe(true); + }); + + it('should return false for FREE plan with 3 users', () => { + expect(SubscriptionPlan.free().canAccommodateUsers(3)).toBe(false); + }); + + it('should return true for STARTER plan with 5 users', () => { + expect(SubscriptionPlan.starter().canAccommodateUsers(5)).toBe(true); + }); + + it('should always return true for ENTERPRISE plan', () => { + expect(SubscriptionPlan.enterprise().canAccommodateUsers(1000)).toBe(true); + }); + }); + + describe('canUpgradeTo', () => { + it('should allow upgrade from FREE to STARTER', () => { + expect(SubscriptionPlan.free().canUpgradeTo(SubscriptionPlan.starter())).toBe(true); + }); + + it('should allow upgrade from FREE to PRO', () => { + expect(SubscriptionPlan.free().canUpgradeTo(SubscriptionPlan.pro())).toBe(true); + }); + + it('should allow upgrade from FREE to ENTERPRISE', () => { + expect(SubscriptionPlan.free().canUpgradeTo(SubscriptionPlan.enterprise())).toBe(true); + }); + + it('should allow upgrade from STARTER to PRO', () => { + expect(SubscriptionPlan.starter().canUpgradeTo(SubscriptionPlan.pro())).toBe(true); + }); + + it('should not allow downgrade from STARTER to FREE', () => { + expect(SubscriptionPlan.starter().canUpgradeTo(SubscriptionPlan.free())).toBe(false); + }); + + it('should not allow same plan upgrade', () => { + expect(SubscriptionPlan.pro().canUpgradeTo(SubscriptionPlan.pro())).toBe(false); + }); + }); + + describe('canDowngradeTo', () => { + it('should allow downgrade from STARTER to FREE when users fit', () => { + expect(SubscriptionPlan.starter().canDowngradeTo(SubscriptionPlan.free(), 1)).toBe(true); + }); + + it('should not allow downgrade from STARTER to FREE when users exceed', () => { + expect(SubscriptionPlan.starter().canDowngradeTo(SubscriptionPlan.free(), 5)).toBe(false); + }); + + it('should not allow upgrade via canDowngradeTo', () => { + expect(SubscriptionPlan.free().canDowngradeTo(SubscriptionPlan.starter(), 1)).toBe(false); + }); + }); + + describe('plan details', () => { + it('should return correct name for FREE plan', () => { + expect(SubscriptionPlan.free().name).toBe('Free'); + }); + + it('should return correct prices for STARTER plan', () => { + const plan = SubscriptionPlan.starter(); + expect(plan.monthlyPriceEur).toBe(49); + expect(plan.yearlyPriceEur).toBe(470); + }); + + it('should return features for PRO plan', () => { + const plan = SubscriptionPlan.pro(); + expect(plan.features).toContain('Up to 20 users'); + expect(plan.features).toContain('API access'); + }); + }); + + describe('getAllPlans', () => { + it('should return all 4 plans', () => { + const plans = SubscriptionPlan.getAllPlans(); + + expect(plans).toHaveLength(4); + expect(plans.map(p => p.value)).toEqual(['FREE', 'STARTER', 'PRO', 'ENTERPRISE']); + }); + }); + + describe('equals', () => { + it('should return true for same plan', () => { + expect(SubscriptionPlan.free().equals(SubscriptionPlan.free())).toBe(true); + }); + + it('should return false for different plans', () => { + expect(SubscriptionPlan.free().equals(SubscriptionPlan.starter())).toBe(false); + }); + }); + + describe('toString', () => { + it('should return plan value as string', () => { + expect(SubscriptionPlan.free().toString()).toBe('FREE'); + expect(SubscriptionPlan.starter().toString()).toBe('STARTER'); + }); + }); +}); diff --git a/apps/backend/src/domain/value-objects/subscription-plan.vo.ts b/apps/backend/src/domain/value-objects/subscription-plan.vo.ts new file mode 100644 index 0000000..b82192a --- /dev/null +++ b/apps/backend/src/domain/value-objects/subscription-plan.vo.ts @@ -0,0 +1,203 @@ +/** + * 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. + */ + +export type SubscriptionPlanType = 'FREE' | 'STARTER' | 'PRO' | 'ENTERPRISE'; + +interface PlanDetails { + readonly name: string; + readonly maxLicenses: number; // -1 means unlimited + readonly monthlyPriceEur: number; + readonly yearlyPriceEur: number; + readonly features: readonly string[]; +} + +const PLAN_DETAILS: Record = { + FREE: { + name: 'Free', + maxLicenses: 2, + monthlyPriceEur: 0, + yearlyPriceEur: 0, + features: [ + 'Up to 2 users', + 'Basic rate search', + 'Email support', + ], + }, + STARTER: { + name: 'Starter', + maxLicenses: 5, + monthlyPriceEur: 49, + yearlyPriceEur: 470, // ~20% discount + features: [ + 'Up to 5 users', + 'Advanced rate search', + 'CSV imports', + 'Priority email support', + ], + }, + PRO: { + name: 'Pro', + maxLicenses: 20, + monthlyPriceEur: 149, + yearlyPriceEur: 1430, // ~20% discount + features: [ + 'Up to 20 users', + 'All Starter features', + 'API access', + 'Custom integrations', + 'Phone support', + ], + }, + ENTERPRISE: { + name: 'Enterprise', + maxLicenses: -1, // unlimited + monthlyPriceEur: 0, // custom pricing + yearlyPriceEur: 0, // custom pricing + features: [ + 'Unlimited users', + 'All Pro features', + 'Dedicated account manager', + 'Custom SLA', + 'On-premise deployment option', + ], + }, +}; + +export class SubscriptionPlan { + private constructor(private readonly plan: SubscriptionPlanType) {} + + static create(plan: SubscriptionPlanType): SubscriptionPlan { + if (!PLAN_DETAILS[plan]) { + throw new Error(`Invalid subscription plan: ${plan}`); + } + return new SubscriptionPlan(plan); + } + + static fromString(value: string): SubscriptionPlan { + const upperValue = value.toUpperCase() as SubscriptionPlanType; + if (!PLAN_DETAILS[upperValue]) { + throw new Error(`Invalid subscription plan: ${value}`); + } + return new SubscriptionPlan(upperValue); + } + + static free(): SubscriptionPlan { + return new SubscriptionPlan('FREE'); + } + + static starter(): SubscriptionPlan { + return new SubscriptionPlan('STARTER'); + } + + static pro(): SubscriptionPlan { + return new SubscriptionPlan('PRO'); + } + + static enterprise(): SubscriptionPlan { + return new SubscriptionPlan('ENTERPRISE'); + } + + static getAllPlans(): SubscriptionPlan[] { + return ['FREE', 'STARTER', 'PRO', 'ENTERPRISE'].map( + (p) => new SubscriptionPlan(p as SubscriptionPlanType), + ); + } + + get value(): SubscriptionPlanType { + return this.plan; + } + + get name(): string { + return PLAN_DETAILS[this.plan].name; + } + + get maxLicenses(): number { + return PLAN_DETAILS[this.plan].maxLicenses; + } + + get monthlyPriceEur(): number { + return PLAN_DETAILS[this.plan].monthlyPriceEur; + } + + get yearlyPriceEur(): number { + return PLAN_DETAILS[this.plan].yearlyPriceEur; + } + + get features(): readonly string[] { + return PLAN_DETAILS[this.plan].features; + } + + /** + * Returns true if this plan has unlimited licenses + */ + isUnlimited(): boolean { + return this.maxLicenses === -1; + } + + /** + * Returns true if this is a paid plan + */ + isPaid(): boolean { + return this.plan !== 'FREE'; + } + + /** + * Returns true if this is the free plan + */ + isFree(): boolean { + return this.plan === 'FREE'; + } + + /** + * Check if a given number of users can be accommodated by this plan + */ + canAccommodateUsers(userCount: number): boolean { + if (this.isUnlimited()) return true; + return userCount <= this.maxLicenses; + } + + /** + * Check if upgrade to target plan is allowed + */ + canUpgradeTo(targetPlan: SubscriptionPlan): boolean { + const planOrder: SubscriptionPlanType[] = [ + 'FREE', + 'STARTER', + 'PRO', + 'ENTERPRISE', + ]; + const currentIndex = planOrder.indexOf(this.plan); + const targetIndex = planOrder.indexOf(targetPlan.value); + return targetIndex > currentIndex; + } + + /** + * 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 currentIndex = planOrder.indexOf(this.plan); + const targetIndex = planOrder.indexOf(targetPlan.value); + + if (targetIndex >= currentIndex) return false; // Not a downgrade + return targetPlan.canAccommodateUsers(currentUserCount); + } + + equals(other: SubscriptionPlan): boolean { + return this.plan === other.plan; + } + + toString(): string { + return this.plan; + } +} diff --git a/apps/backend/src/domain/value-objects/subscription-status.vo.ts b/apps/backend/src/domain/value-objects/subscription-status.vo.ts new file mode 100644 index 0000000..de87862 --- /dev/null +++ b/apps/backend/src/domain/value-objects/subscription-status.vo.ts @@ -0,0 +1,215 @@ +/** + * Subscription Status Value Object + * + * Represents the different statuses a subscription can have. + * Follows Stripe subscription lifecycle states. + */ + +export type SubscriptionStatusType = + | 'ACTIVE' + | 'PAST_DUE' + | 'CANCELED' + | 'INCOMPLETE' + | 'INCOMPLETE_EXPIRED' + | 'TRIALING' + | 'UNPAID' + | 'PAUSED'; + +interface StatusDetails { + readonly label: string; + readonly description: string; + readonly allowsAccess: boolean; + readonly requiresAction: boolean; +} + +const STATUS_DETAILS: Record = { + ACTIVE: { + label: 'Active', + description: 'Subscription is active and fully paid', + allowsAccess: true, + requiresAction: false, + }, + PAST_DUE: { + label: 'Past Due', + description: 'Payment failed but subscription still active. Action required.', + allowsAccess: true, // Grace period + requiresAction: true, + }, + CANCELED: { + label: 'Canceled', + description: 'Subscription has been canceled', + allowsAccess: false, + requiresAction: false, + }, + INCOMPLETE: { + label: 'Incomplete', + description: 'Initial payment failed during subscription creation', + allowsAccess: false, + requiresAction: true, + }, + INCOMPLETE_EXPIRED: { + label: 'Incomplete Expired', + description: 'Subscription creation payment window expired', + allowsAccess: false, + requiresAction: false, + }, + TRIALING: { + label: 'Trialing', + description: 'Subscription is in trial period', + allowsAccess: true, + requiresAction: false, + }, + UNPAID: { + label: 'Unpaid', + description: 'All payment retry attempts have failed', + allowsAccess: false, + requiresAction: true, + }, + PAUSED: { + label: 'Paused', + description: 'Subscription has been paused', + allowsAccess: false, + requiresAction: false, + }, +}; + +// Status transitions that are valid +const VALID_TRANSITIONS: Record = { + ACTIVE: ['PAST_DUE', 'CANCELED', 'PAUSED'], + PAST_DUE: ['ACTIVE', 'CANCELED', 'UNPAID'], + CANCELED: [], // Terminal state + INCOMPLETE: ['ACTIVE', 'INCOMPLETE_EXPIRED'], + INCOMPLETE_EXPIRED: [], // Terminal state + TRIALING: ['ACTIVE', 'PAST_DUE', 'CANCELED'], + UNPAID: ['ACTIVE', 'CANCELED'], + PAUSED: ['ACTIVE', 'CANCELED'], +}; + +export class SubscriptionStatus { + private constructor(private readonly status: SubscriptionStatusType) {} + + static create(status: SubscriptionStatusType): SubscriptionStatus { + if (!STATUS_DETAILS[status]) { + throw new Error(`Invalid subscription status: ${status}`); + } + return new SubscriptionStatus(status); + } + + static fromString(value: string): SubscriptionStatus { + const upperValue = value.toUpperCase().replace(/-/g, '_') as SubscriptionStatusType; + if (!STATUS_DETAILS[upperValue]) { + throw new Error(`Invalid subscription status: ${value}`); + } + return new SubscriptionStatus(upperValue); + } + + static fromStripeStatus(stripeStatus: string): SubscriptionStatus { + // Map Stripe status to our internal status + const mapping: Record = { + active: 'ACTIVE', + past_due: 'PAST_DUE', + canceled: 'CANCELED', + incomplete: 'INCOMPLETE', + incomplete_expired: 'INCOMPLETE_EXPIRED', + trialing: 'TRIALING', + unpaid: 'UNPAID', + paused: 'PAUSED', + }; + + const mappedStatus = mapping[stripeStatus.toLowerCase()]; + if (!mappedStatus) { + throw new Error(`Unknown Stripe subscription status: ${stripeStatus}`); + } + return new SubscriptionStatus(mappedStatus); + } + + static active(): SubscriptionStatus { + return new SubscriptionStatus('ACTIVE'); + } + + static canceled(): SubscriptionStatus { + return new SubscriptionStatus('CANCELED'); + } + + static pastDue(): SubscriptionStatus { + return new SubscriptionStatus('PAST_DUE'); + } + + static trialing(): SubscriptionStatus { + return new SubscriptionStatus('TRIALING'); + } + + get value(): SubscriptionStatusType { + return this.status; + } + + get label(): string { + return STATUS_DETAILS[this.status].label; + } + + get description(): string { + return STATUS_DETAILS[this.status].description; + } + + /** + * Returns true if this status allows access to the platform + */ + allowsAccess(): boolean { + return STATUS_DETAILS[this.status].allowsAccess; + } + + /** + * Returns true if this status requires user action (e.g., update payment method) + */ + requiresAction(): boolean { + return STATUS_DETAILS[this.status].requiresAction; + } + + /** + * Returns true if this is a terminal state (cannot transition out) + */ + isTerminal(): boolean { + return VALID_TRANSITIONS[this.status].length === 0; + } + + /** + * Returns true if the subscription is in good standing + */ + isInGoodStanding(): boolean { + return this.status === 'ACTIVE' || this.status === 'TRIALING'; + } + + /** + * Check if transition to new status is valid + */ + canTransitionTo(newStatus: SubscriptionStatus): boolean { + return VALID_TRANSITIONS[this.status].includes(newStatus.value); + } + + /** + * Transition to new status if valid + */ + transitionTo(newStatus: SubscriptionStatus): SubscriptionStatus { + if (!this.canTransitionTo(newStatus)) { + throw new Error( + `Invalid status transition from ${this.status} to ${newStatus.value}`, + ); + } + return newStatus; + } + + equals(other: SubscriptionStatus): boolean { + return this.status === other.status; + } + + toString(): string { + return this.status; + } + + /** + * Convert to Stripe-compatible status string + */ + toStripeStatus(): string { + return this.status.toLowerCase().replace(/_/g, '-'); + } +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/entities/index.ts b/apps/backend/src/infrastructure/persistence/typeorm/entities/index.ts index 31d7f2c..7d3cac9 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/entities/index.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/entities/index.ts @@ -10,3 +10,5 @@ export * from './carrier.orm-entity'; export * from './port.orm-entity'; export * from './rate-quote.orm-entity'; export * from './csv-rate-config.orm-entity'; +export * from './subscription.orm-entity'; +export * from './license.orm-entity'; 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 new file mode 100644 index 0000000..afde22a --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/entities/license.orm-entity.ts @@ -0,0 +1,60 @@ +/** + * License ORM Entity (Infrastructure Layer) + * + * TypeORM entity for license persistence. + * Represents user licenses linked to subscriptions. + */ + +import { + Entity, + Column, + PrimaryGeneratedColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { SubscriptionOrmEntity } from './subscription.orm-entity'; +import { UserOrmEntity } from './user.orm-entity'; + +export type LicenseStatusOrmType = 'ACTIVE' | 'REVOKED'; + +@Entity('licenses') +@Index('idx_licenses_subscription_id', ['subscriptionId']) +@Index('idx_licenses_user_id', ['userId']) +@Index('idx_licenses_status', ['status']) +@Index('idx_licenses_subscription_status', ['subscriptionId', 'status']) +export class LicenseOrmEntity { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'subscription_id', type: 'uuid' }) + subscriptionId: string; + + @ManyToOne(() => SubscriptionOrmEntity, (subscription) => subscription.licenses, { + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'subscription_id' }) + subscription: SubscriptionOrmEntity; + + @Column({ name: 'user_id', type: 'uuid', unique: true }) + userId: string; + + @ManyToOne(() => UserOrmEntity, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'user_id' }) + user: UserOrmEntity; + + // Status + @Column({ + type: 'enum', + enum: ['ACTIVE', 'REVOKED'], + default: 'ACTIVE', + }) + status: LicenseStatusOrmType; + + // Timestamps + @Column({ name: 'assigned_at', type: 'timestamp', default: () => 'NOW()' }) + assignedAt: Date; + + @Column({ name: 'revoked_at', type: 'timestamp', nullable: true }) + revokedAt: Date | null; +} 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 new file mode 100644 index 0000000..941b744 --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/entities/subscription.orm-entity.ts @@ -0,0 +1,108 @@ +/** + * Subscription ORM Entity (Infrastructure Layer) + * + * TypeORM entity for subscription persistence. + * Represents organization subscriptions with plan and Stripe integration. + */ + +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + JoinColumn, + Index, +} from 'typeorm'; +import { OrganizationOrmEntity } from './organization.orm-entity'; +import { LicenseOrmEntity } from './license.orm-entity'; + +export type SubscriptionPlanOrmType = 'FREE' | 'STARTER' | 'PRO' | 'ENTERPRISE'; + +export type SubscriptionStatusOrmType = + | 'ACTIVE' + | 'PAST_DUE' + | 'CANCELED' + | 'INCOMPLETE' + | 'INCOMPLETE_EXPIRED' + | 'TRIALING' + | 'UNPAID' + | 'PAUSED'; + +@Entity('subscriptions') +@Index('idx_subscriptions_organization_id', ['organizationId']) +@Index('idx_subscriptions_stripe_customer_id', ['stripeCustomerId']) +@Index('idx_subscriptions_stripe_subscription_id', ['stripeSubscriptionId']) +@Index('idx_subscriptions_plan', ['plan']) +@Index('idx_subscriptions_status', ['status']) +export class SubscriptionOrmEntity { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'organization_id', type: 'uuid', unique: true }) + organizationId: string; + + @ManyToOne(() => OrganizationOrmEntity, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'organization_id' }) + organization: OrganizationOrmEntity; + + // Plan information + @Column({ + type: 'enum', + enum: ['FREE', 'STARTER', 'PRO', 'ENTERPRISE'], + default: 'FREE', + }) + plan: SubscriptionPlanOrmType; + + @Column({ + type: 'enum', + enum: [ + 'ACTIVE', + 'PAST_DUE', + 'CANCELED', + 'INCOMPLETE', + 'INCOMPLETE_EXPIRED', + 'TRIALING', + 'UNPAID', + 'PAUSED', + ], + default: 'ACTIVE', + }) + status: SubscriptionStatusOrmType; + + // Stripe integration + @Column({ name: 'stripe_customer_id', type: 'varchar', length: 255, nullable: true }) + stripeCustomerId: string | null; + + @Column({ + name: 'stripe_subscription_id', + type: 'varchar', + length: 255, + nullable: true, + unique: true, + }) + stripeSubscriptionId: string | null; + + // Billing period + @Column({ name: 'current_period_start', type: 'timestamp', nullable: true }) + currentPeriodStart: Date | null; + + @Column({ name: 'current_period_end', type: 'timestamp', nullable: true }) + currentPeriodEnd: Date | null; + + @Column({ name: 'cancel_at_period_end', type: 'boolean', default: false }) + cancelAtPeriodEnd: boolean; + + // Timestamps + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; + + // Relations + @OneToMany(() => LicenseOrmEntity, (license) => license.subscription) + licenses: LicenseOrmEntity[]; +} 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 778685f..66a8912 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 @@ -14,9 +14,12 @@ import { CsvBookingOrmEntity } from '../entities/csv-booking.orm-entity'; export class CsvBookingMapper { /** * Map ORM entity to domain entity + * + * Uses fromPersistence to avoid validation errors when loading legacy data + * that might have empty documents array */ static toDomain(ormEntity: CsvBookingOrmEntity): CsvBooking { - return new CsvBooking( + return CsvBooking.fromPersistence( ormEntity.id, ormEntity.userId, ormEntity.organizationId, diff --git a/apps/backend/src/infrastructure/persistence/typeorm/mappers/index.ts b/apps/backend/src/infrastructure/persistence/typeorm/mappers/index.ts index 7521113..f50e66c 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/mappers/index.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/mappers/index.ts @@ -9,3 +9,5 @@ export * from './user-orm.mapper'; export * from './carrier-orm.mapper'; export * from './port-orm.mapper'; export * from './rate-quote-orm.mapper'; +export * from './subscription-orm.mapper'; +export * from './license-orm.mapper'; 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 new file mode 100644 index 0000000..9a4ceb5 --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/mappers/license-orm.mapper.ts @@ -0,0 +1,48 @@ +/** + * License ORM Mapper + * + * Maps between License domain entity and LicenseOrmEntity + */ + +import { License } from '@domain/entities/license.entity'; +import { LicenseOrmEntity } from '../entities/license.orm-entity'; + +export class LicenseOrmMapper { + /** + * Map domain entity to ORM entity + */ + static toOrm(domain: License): LicenseOrmEntity { + const orm = new LicenseOrmEntity(); + const props = domain.toObject(); + + orm.id = props.id; + orm.subscriptionId = props.subscriptionId; + orm.userId = props.userId; + orm.status = props.status; + orm.assignedAt = props.assignedAt; + orm.revokedAt = props.revokedAt; + + return orm; + } + + /** + * Map ORM entity to domain entity + */ + static toDomain(orm: LicenseOrmEntity): License { + return License.fromPersistence({ + id: orm.id, + subscriptionId: orm.subscriptionId, + userId: orm.userId, + status: orm.status, + assignedAt: orm.assignedAt, + revokedAt: orm.revokedAt, + }); + } + + /** + * Map array of ORM entities to domain entities + */ + static toDomainMany(orms: LicenseOrmEntity[]): License[] { + return orms.map((orm) => this.toDomain(orm)); + } +} 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 new file mode 100644 index 0000000..95c65d0 --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/mappers/subscription-orm.mapper.ts @@ -0,0 +1,58 @@ +/** + * Subscription ORM Mapper + * + * Maps between Subscription domain entity and SubscriptionOrmEntity + */ + +import { Subscription } from '@domain/entities/subscription.entity'; +import { SubscriptionOrmEntity } from '../entities/subscription.orm-entity'; + +export class SubscriptionOrmMapper { + /** + * Map domain entity to ORM entity + */ + static toOrm(domain: Subscription): SubscriptionOrmEntity { + const orm = new SubscriptionOrmEntity(); + const props = domain.toObject(); + + orm.id = props.id; + orm.organizationId = props.organizationId; + orm.plan = props.plan; + orm.status = props.status; + orm.stripeCustomerId = props.stripeCustomerId; + orm.stripeSubscriptionId = props.stripeSubscriptionId; + orm.currentPeriodStart = props.currentPeriodStart; + orm.currentPeriodEnd = props.currentPeriodEnd; + orm.cancelAtPeriodEnd = props.cancelAtPeriodEnd; + orm.createdAt = props.createdAt; + orm.updatedAt = props.updatedAt; + + return orm; + } + + /** + * Map ORM entity to domain entity + */ + static toDomain(orm: SubscriptionOrmEntity): Subscription { + return Subscription.fromPersistence({ + id: orm.id, + organizationId: orm.organizationId, + plan: orm.plan, + status: orm.status, + stripeCustomerId: orm.stripeCustomerId, + stripeSubscriptionId: orm.stripeSubscriptionId, + currentPeriodStart: orm.currentPeriodStart, + currentPeriodEnd: orm.currentPeriodEnd, + cancelAtPeriodEnd: orm.cancelAtPeriodEnd, + createdAt: orm.createdAt, + updatedAt: orm.updatedAt, + }); + } + + /** + * Map array of ORM entities to domain entities + */ + static toDomainMany(orms: SubscriptionOrmEntity[]): Subscription[] { + return orms.map((orm) => this.toDomain(orm)); + } +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/migrations/1738000000001-CreateSubscriptions.ts b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1738000000001-CreateSubscriptions.ts new file mode 100644 index 0000000..c28f7fd --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1738000000001-CreateSubscriptions.ts @@ -0,0 +1,98 @@ +/** + * Migration: Create Subscriptions Table + * + * This table stores organization subscription information including + * plan, status, and Stripe integration data. + */ + +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateSubscriptions1738000000001 implements MigrationInterface { + name = 'CreateSubscriptions1738000000001'; + + public async up(queryRunner: QueryRunner): Promise { + // Create subscription_plan enum + await queryRunner.query(` + CREATE TYPE "subscription_plan_enum" AS ENUM ('FREE', 'STARTER', 'PRO', 'ENTERPRISE') + `); + + // Create subscription_status enum + await queryRunner.query(` + CREATE TYPE "subscription_status_enum" AS ENUM ( + 'ACTIVE', + 'PAST_DUE', + 'CANCELED', + 'INCOMPLETE', + 'INCOMPLETE_EXPIRED', + 'TRIALING', + 'UNPAID', + 'PAUSED' + ) + `); + + // Create subscriptions table + await queryRunner.query(` + CREATE TABLE "subscriptions" ( + "id" UUID NOT NULL DEFAULT uuid_generate_v4(), + "organization_id" UUID NOT NULL, + + -- Plan information + "plan" subscription_plan_enum NOT NULL DEFAULT 'FREE', + "status" subscription_status_enum NOT NULL DEFAULT 'ACTIVE', + + -- Stripe integration + "stripe_customer_id" VARCHAR(255) NULL, + "stripe_subscription_id" VARCHAR(255) NULL, + + -- Billing period + "current_period_start" TIMESTAMP NULL, + "current_period_end" TIMESTAMP NULL, + "cancel_at_period_end" BOOLEAN NOT NULL DEFAULT FALSE, + + -- Timestamps + "created_at" TIMESTAMP NOT NULL DEFAULT NOW(), + "updated_at" TIMESTAMP NOT NULL DEFAULT NOW(), + + CONSTRAINT "pk_subscriptions" PRIMARY KEY ("id"), + CONSTRAINT "uq_subscriptions_organization_id" UNIQUE ("organization_id"), + CONSTRAINT "uq_subscriptions_stripe_subscription_id" UNIQUE ("stripe_subscription_id"), + CONSTRAINT "fk_subscriptions_organization" FOREIGN KEY ("organization_id") + REFERENCES "organizations"("id") ON DELETE CASCADE + ) + `); + + // Create indexes + await queryRunner.query(` + CREATE INDEX "idx_subscriptions_organization_id" ON "subscriptions" ("organization_id") + `); + await queryRunner.query(` + CREATE INDEX "idx_subscriptions_stripe_customer_id" ON "subscriptions" ("stripe_customer_id") + `); + await queryRunner.query(` + CREATE INDEX "idx_subscriptions_stripe_subscription_id" ON "subscriptions" ("stripe_subscription_id") + `); + await queryRunner.query(` + CREATE INDEX "idx_subscriptions_plan" ON "subscriptions" ("plan") + `); + await queryRunner.query(` + CREATE INDEX "idx_subscriptions_status" ON "subscriptions" ("status") + `); + + // Add comments + await queryRunner.query(` + COMMENT ON TABLE "subscriptions" IS 'Organization subscriptions for licensing system' + `); + await queryRunner.query(` + COMMENT ON COLUMN "subscriptions"."plan" IS 'Subscription plan: FREE (2 users), STARTER (5), PRO (20), ENTERPRISE (unlimited)' + `); + await queryRunner.query(` + COMMENT ON COLUMN "subscriptions"."cancel_at_period_end" IS 'If true, subscription will be canceled at the end of current period' + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS "subscriptions" CASCADE`); + await queryRunner.query(`DROP TYPE IF EXISTS "subscription_status_enum"`); + await queryRunner.query(`DROP TYPE IF EXISTS "subscription_plan_enum"`); + } +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/migrations/1738000000002-CreateLicenses.ts b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1738000000002-CreateLicenses.ts new file mode 100644 index 0000000..d48a8fc --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1738000000002-CreateLicenses.ts @@ -0,0 +1,72 @@ +/** + * Migration: Create Licenses Table + * + * This table stores user licenses linked to subscriptions. + * Each active user in an organization consumes one license. + */ + +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateLicenses1738000000002 implements MigrationInterface { + name = 'CreateLicenses1738000000002'; + + public async up(queryRunner: QueryRunner): Promise { + // Create license_status enum + await queryRunner.query(` + CREATE TYPE "license_status_enum" AS ENUM ('ACTIVE', 'REVOKED') + `); + + // Create licenses table + await queryRunner.query(` + CREATE TABLE "licenses" ( + "id" UUID NOT NULL DEFAULT uuid_generate_v4(), + "subscription_id" UUID NOT NULL, + "user_id" UUID NOT NULL, + + -- Status + "status" license_status_enum NOT NULL DEFAULT 'ACTIVE', + + -- Timestamps + "assigned_at" TIMESTAMP NOT NULL DEFAULT NOW(), + "revoked_at" TIMESTAMP NULL, + + CONSTRAINT "pk_licenses" PRIMARY KEY ("id"), + CONSTRAINT "uq_licenses_user_id" UNIQUE ("user_id"), + CONSTRAINT "fk_licenses_subscription" FOREIGN KEY ("subscription_id") + REFERENCES "subscriptions"("id") ON DELETE CASCADE, + CONSTRAINT "fk_licenses_user" FOREIGN KEY ("user_id") + REFERENCES "users"("id") ON DELETE CASCADE + ) + `); + + // Create indexes + await queryRunner.query(` + CREATE INDEX "idx_licenses_subscription_id" ON "licenses" ("subscription_id") + `); + await queryRunner.query(` + CREATE INDEX "idx_licenses_user_id" ON "licenses" ("user_id") + `); + await queryRunner.query(` + CREATE INDEX "idx_licenses_status" ON "licenses" ("status") + `); + await queryRunner.query(` + CREATE INDEX "idx_licenses_subscription_status" ON "licenses" ("subscription_id", "status") + `); + + // Add comments + await queryRunner.query(` + COMMENT ON TABLE "licenses" IS 'User licenses for subscription-based access control' + `); + await queryRunner.query(` + COMMENT ON COLUMN "licenses"."status" IS 'ACTIVE: license in use, REVOKED: license freed up' + `); + await queryRunner.query(` + COMMENT ON COLUMN "licenses"."revoked_at" IS 'Timestamp when license was revoked, NULL if still active' + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS "licenses" CASCADE`); + await queryRunner.query(`DROP TYPE IF EXISTS "license_status_enum"`); + } +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/migrations/1738000000003-SeedFreeSubscriptions.ts b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1738000000003-SeedFreeSubscriptions.ts new file mode 100644 index 0000000..536aa32 --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1738000000003-SeedFreeSubscriptions.ts @@ -0,0 +1,75 @@ +/** + * Migration: Seed FREE Subscriptions for Existing Organizations + * + * Creates a FREE subscription for all existing organizations that don't have one, + * and assigns licenses to all their active users. + */ + +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class SeedFreeSubscriptions1738000000003 implements MigrationInterface { + name = 'SeedFreeSubscriptions1738000000003'; + + public async up(queryRunner: QueryRunner): Promise { + // Create FREE subscription for each organization that doesn't have one + await queryRunner.query(` + INSERT INTO "subscriptions" ( + "id", + "organization_id", + "plan", + "status", + "created_at", + "updated_at" + ) + SELECT + uuid_generate_v4(), + o.id, + 'FREE', + 'ACTIVE', + NOW(), + NOW() + FROM "organizations" o + WHERE NOT EXISTS ( + SELECT 1 FROM "subscriptions" s WHERE s.organization_id = o.id + ) + `); + + // Assign licenses to all active users in organizations with subscriptions + await queryRunner.query(` + INSERT INTO "licenses" ( + "id", + "subscription_id", + "user_id", + "status", + "assigned_at" + ) + SELECT + uuid_generate_v4(), + s.id, + u.id, + 'ACTIVE', + NOW() + FROM "users" u + INNER JOIN "subscriptions" s ON s.organization_id = u.organization_id + WHERE u.is_active = true + AND NOT EXISTS ( + SELECT 1 FROM "licenses" l WHERE l.user_id = u.id + ) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + // Remove licenses created by this migration + // Note: This is a destructive operation that cannot perfectly reverse + // We'll delete all licenses and subscriptions with FREE plan created after a certain point + // In practice, you wouldn't typically revert this in production + + await queryRunner.query(` + DELETE FROM "licenses" + `); + + await queryRunner.query(` + DELETE FROM "subscriptions" WHERE "plan" = 'FREE' + `); + } +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/repositories/index.ts b/apps/backend/src/infrastructure/persistence/typeorm/repositories/index.ts index cefb832..3b1a091 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/repositories/index.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/repositories/index.ts @@ -9,3 +9,5 @@ export * from './typeorm-user.repository'; export * from './typeorm-carrier.repository'; export * from './typeorm-port.repository'; export * from './typeorm-rate-quote.repository'; +export * from './typeorm-subscription.repository'; +export * from './typeorm-license.repository'; 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 new file mode 100644 index 0000000..8c74cd6 --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-license.repository.ts @@ -0,0 +1,90 @@ +/** + * TypeORM License Repository + * + * Implements LicenseRepository interface using TypeORM + */ + +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { License } from '@domain/entities/license.entity'; +import { LicenseRepository } from '@domain/ports/out/license.repository'; +import { LicenseOrmEntity } from '../entities/license.orm-entity'; +import { LicenseOrmMapper } from '../mappers/license-orm.mapper'; + +@Injectable() +export class TypeOrmLicenseRepository implements LicenseRepository { + constructor( + @InjectRepository(LicenseOrmEntity) + private readonly repository: Repository, + ) {} + + async save(license: License): Promise { + const orm = LicenseOrmMapper.toOrm(license); + const saved = await this.repository.save(orm); + return LicenseOrmMapper.toDomain(saved); + } + + async findById(id: string): Promise { + const orm = await this.repository.findOne({ where: { id } }); + return orm ? LicenseOrmMapper.toDomain(orm) : null; + } + + async findByUserId(userId: string): Promise { + const orm = await this.repository.findOne({ where: { userId } }); + return orm ? LicenseOrmMapper.toDomain(orm) : null; + } + + async findBySubscriptionId(subscriptionId: string): Promise { + const orms = await this.repository.find({ + where: { subscriptionId }, + order: { assignedAt: 'DESC' }, + }); + return LicenseOrmMapper.toDomainMany(orms); + } + + async findActiveBySubscriptionId(subscriptionId: string): Promise { + const orms = await this.repository.find({ + where: { subscriptionId, status: 'ACTIVE' }, + order: { assignedAt: 'DESC' }, + }); + return LicenseOrmMapper.toDomainMany(orms); + } + + async countActiveBySubscriptionId(subscriptionId: string): Promise { + return this.repository.count({ + where: { subscriptionId, status: 'ACTIVE' }, + }); + } + + async countActiveBySubscriptionIdExcludingAdmins(subscriptionId: string): Promise { + const result = await this.repository + .createQueryBuilder('license') + .innerJoin('license.user', 'user') + .where('license.subscriptionId = :subscriptionId', { subscriptionId }) + .andWhere('license.status = :status', { status: 'ACTIVE' }) + .andWhere('user.role != :adminRole', { adminRole: 'ADMIN' }) + .getCount(); + return result; + } + + async findActiveBySubscriptionIdExcludingAdmins(subscriptionId: string): Promise { + const orms = await this.repository + .createQueryBuilder('license') + .innerJoin('license.user', 'user') + .where('license.subscriptionId = :subscriptionId', { subscriptionId }) + .andWhere('license.status = :status', { status: 'ACTIVE' }) + .andWhere('user.role != :adminRole', { adminRole: 'ADMIN' }) + .orderBy('license.assignedAt', 'DESC') + .getMany(); + return LicenseOrmMapper.toDomainMany(orms); + } + + async delete(id: string): Promise { + await this.repository.delete({ id }); + } + + async deleteBySubscriptionId(subscriptionId: string): Promise { + await this.repository.delete({ subscriptionId }); + } +} 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 new file mode 100644 index 0000000..5469475 --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-subscription.repository.ts @@ -0,0 +1,60 @@ +/** + * TypeORM Subscription Repository + * + * Implements SubscriptionRepository interface using TypeORM + */ + +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Subscription } from '@domain/entities/subscription.entity'; +import { SubscriptionRepository } from '@domain/ports/out/subscription.repository'; +import { SubscriptionOrmEntity } from '../entities/subscription.orm-entity'; +import { SubscriptionOrmMapper } from '../mappers/subscription-orm.mapper'; + +@Injectable() +export class TypeOrmSubscriptionRepository implements SubscriptionRepository { + constructor( + @InjectRepository(SubscriptionOrmEntity) + private readonly repository: Repository, + ) {} + + async save(subscription: Subscription): Promise { + const orm = SubscriptionOrmMapper.toOrm(subscription); + const saved = await this.repository.save(orm); + return SubscriptionOrmMapper.toDomain(saved); + } + + async findById(id: string): Promise { + const orm = await this.repository.findOne({ where: { id } }); + return orm ? SubscriptionOrmMapper.toDomain(orm) : null; + } + + async findByOrganizationId(organizationId: string): Promise { + const orm = await this.repository.findOne({ where: { organizationId } }); + return orm ? SubscriptionOrmMapper.toDomain(orm) : null; + } + + async findByStripeSubscriptionId( + stripeSubscriptionId: string, + ): Promise { + const orm = await this.repository.findOne({ where: { stripeSubscriptionId } }); + return orm ? SubscriptionOrmMapper.toDomain(orm) : null; + } + + async findByStripeCustomerId(stripeCustomerId: string): Promise { + const orm = await this.repository.findOne({ where: { stripeCustomerId } }); + return orm ? SubscriptionOrmMapper.toDomain(orm) : null; + } + + async findAll(): Promise { + const orms = await this.repository.find({ + order: { createdAt: 'DESC' }, + }); + return SubscriptionOrmMapper.toDomainMany(orms); + } + + async delete(id: string): Promise { + await this.repository.delete({ id }); + } +} diff --git a/apps/backend/src/infrastructure/stripe/index.ts b/apps/backend/src/infrastructure/stripe/index.ts new file mode 100644 index 0000000..fee172c --- /dev/null +++ b/apps/backend/src/infrastructure/stripe/index.ts @@ -0,0 +1,6 @@ +/** + * Stripe Infrastructure Barrel Export + */ + +export * from './stripe.adapter'; +export * from './stripe.module'; diff --git a/apps/backend/src/infrastructure/stripe/stripe.adapter.ts b/apps/backend/src/infrastructure/stripe/stripe.adapter.ts new file mode 100644 index 0000000..cf5386a --- /dev/null +++ b/apps/backend/src/infrastructure/stripe/stripe.adapter.ts @@ -0,0 +1,233 @@ +/** + * Stripe Adapter + * + * Implementation of the StripePort interface using the Stripe SDK. + */ + +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import Stripe from 'stripe'; +import { + StripePort, + CreateCheckoutSessionInput, + CreateCheckoutSessionOutput, + CreatePortalSessionInput, + CreatePortalSessionOutput, + StripeSubscriptionData, + StripeCheckoutSessionData, + StripeWebhookEvent, +} from '@domain/ports/out/stripe.port'; +import { SubscriptionPlanType } from '@domain/value-objects/subscription-plan.vo'; + +@Injectable() +export class StripeAdapter implements StripePort { + private readonly logger = new Logger(StripeAdapter.name); + private readonly stripe: Stripe; + private readonly webhookSecret: string; + private readonly priceIdMap: Map; + private readonly planPriceMap: Map; + + constructor(private readonly configService: ConfigService) { + const apiKey = this.configService.get('STRIPE_SECRET_KEY'); + if (!apiKey) { + this.logger.warn('STRIPE_SECRET_KEY not configured - Stripe features will be disabled'); + } + + this.stripe = new Stripe(apiKey || 'sk_test_placeholder'); + + this.webhookSecret = this.configService.get('STRIPE_WEBHOOK_SECRET') || ''; + + // Map Stripe price IDs to plans + this.priceIdMap = new Map(); + 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'); + + 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'); + + this.planPriceMap.set('STARTER', { + monthly: starterMonthly || '', + yearly: starterYearly || '', + }); + this.planPriceMap.set('PRO', { + monthly: proMonthly || '', + yearly: proYearly || '', + }); + this.planPriceMap.set('ENTERPRISE', { + monthly: enterpriseMonthly || '', + yearly: enterpriseYearly || '', + }); + } + + async createCheckoutSession( + 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; + + if (!priceId) { + throw new Error( + `No ${input.billingInterval} price configured for plan: ${input.plan}`, + ); + } + + const sessionParams: Stripe.Checkout.SessionCreateParams = { + mode: 'subscription', + payment_method_types: ['card'], + line_items: [ + { + price: priceId, + quantity: 1, + }, + ], + success_url: input.successUrl, + cancel_url: input.cancelUrl, + customer_email: input.customerId ? undefined : input.email, + customer: input.customerId || undefined, + metadata: { + organizationId: input.organizationId, + organizationName: input.organizationName, + plan: input.plan, + }, + subscription_data: { + metadata: { + organizationId: input.organizationId, + plan: input.plan, + }, + }, + allow_promotion_codes: true, + billing_address_collection: 'required', + }; + + const session = await this.stripe.checkout.sessions.create(sessionParams); + + this.logger.log( + `Created checkout session ${session.id} for organization ${input.organizationId}`, + ); + + 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, + }); + + this.logger.log(`Created portal session for customer ${input.customerId}`); + + return { + sessionUrl: session.url, + }; + } + + async getSubscription(subscriptionId: string): Promise { + try { + const subscription = await this.stripe.subscriptions.retrieve(subscriptionId); + + // Get the price ID from the first item + const priceId = subscription.items.data[0]?.price.id || ''; + + return { + subscriptionId: subscription.id, + customerId: subscription.customer as string, + status: subscription.status, + planId: priceId, + currentPeriodStart: new Date(subscription.current_period_start * 1000), + currentPeriodEnd: new Date(subscription.current_period_end * 1000), + cancelAtPeriodEnd: subscription.cancel_at_period_end, + }; + } catch (error) { + if ((error as any).code === 'resource_missing') { + return null; + } + throw error; + } + } + + async getCheckoutSession(sessionId: string): Promise { + try { + const session = await this.stripe.checkout.sessions.retrieve(sessionId); + + return { + sessionId: session.id, + customerId: session.customer as string | null, + subscriptionId: session.subscription as string | null, + status: session.status || 'unknown', + metadata: (session.metadata || {}) as Record, + }; + } catch (error) { + if ((error as any).code === 'resource_missing') { + return null; + } + this.logger.error(`Failed to retrieve checkout session ${sessionId}:`, error); + throw error; + } + } + + async cancelSubscriptionAtPeriodEnd(subscriptionId: string): Promise { + await this.stripe.subscriptions.update(subscriptionId, { + cancel_at_period_end: true, + }); + + this.logger.log(`Scheduled subscription ${subscriptionId} for cancellation at period end`); + } + + async cancelSubscriptionImmediately(subscriptionId: string): Promise { + await this.stripe.subscriptions.cancel(subscriptionId); + + this.logger.log(`Cancelled subscription ${subscriptionId} immediately`); + } + + async resumeSubscription(subscriptionId: string): Promise { + await this.stripe.subscriptions.update(subscriptionId, { + cancel_at_period_end: false, + }); + + this.logger.log(`Resumed subscription ${subscriptionId}`); + } + + async constructWebhookEvent( + payload: string | Buffer, + signature: string, + ): Promise { + const event = this.stripe.webhooks.constructEvent( + payload, + signature, + this.webhookSecret, + ); + + return { + type: event.type, + data: { + object: event.data.object as Record, + }, + }; + } + + mapPriceIdToPlan(priceId: string): SubscriptionPlanType | null { + return this.priceIdMap.get(priceId) || null; + } +} diff --git a/apps/backend/src/infrastructure/stripe/stripe.module.ts b/apps/backend/src/infrastructure/stripe/stripe.module.ts new file mode 100644 index 0000000..47654a9 --- /dev/null +++ b/apps/backend/src/infrastructure/stripe/stripe.module.ts @@ -0,0 +1,23 @@ +/** + * Stripe Module + * + * NestJS module for Stripe payment integration. + */ + +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { StripeAdapter } from './stripe.adapter'; +import { STRIPE_PORT } from '@domain/ports/out/stripe.port'; + +@Module({ + imports: [ConfigModule], + providers: [ + StripeAdapter, + { + provide: STRIPE_PORT, + useExisting: StripeAdapter, + }, + ], + exports: [STRIPE_PORT, StripeAdapter], +}) +export class StripeModule {} diff --git a/apps/backend/src/main.ts b/apps/backend/src/main.ts index ad779af..ed82eae 100644 --- a/apps/backend/src/main.ts +++ b/apps/backend/src/main.ts @@ -11,6 +11,8 @@ import { helmetConfig, corsConfig } from './infrastructure/security/security.con async function bootstrap() { const app = await NestFactory.create(AppModule, { bufferLogs: true, + // Enable rawBody for Stripe webhooks signature verification + rawBody: true, }); // Get config service diff --git a/apps/frontend/app/dashboard/documents/page.tsx b/apps/frontend/app/dashboard/documents/page.tsx new file mode 100644 index 0000000..d9b90e8 --- /dev/null +++ b/apps/frontend/app/dashboard/documents/page.tsx @@ -0,0 +1,981 @@ +'use client'; + +import { useState, useEffect, useCallback, useRef } from 'react'; +import { listCsvBookings, CsvBookingResponse } from '@/lib/api/bookings'; + +interface Document { + id: string; + fileName: string; + filePath: string; + type: string; + mimeType: string; + size: number; + uploadedAt?: Date; + // Legacy fields for compatibility + name?: string; + url?: string; +} + +interface DocumentWithBooking extends Document { + bookingId: string; + quoteNumber: string; + route: string; + status: string; + carrierName: string; + fileType?: string; +} + +export default function UserDocumentsPage() { + const [bookings, setBookings] = useState([]); + const [documents, setDocuments] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [searchTerm, setSearchTerm] = useState(''); + const [filterStatus, setFilterStatus] = useState('all'); + const [filterQuoteNumber, setFilterQuoteNumber] = useState(''); + const [currentPage, setCurrentPage] = useState(1); + const [itemsPerPage, setItemsPerPage] = useState(10); + + // Modal state for adding documents + const [showAddModal, setShowAddModal] = useState(false); + const [selectedBookingId, setSelectedBookingId] = useState(null); + const [uploadingFiles, setUploadingFiles] = useState(false); + const fileInputRef = useRef(null); + + // Modal state for replacing documents + const [showReplaceModal, setShowReplaceModal] = useState(false); + const [documentToReplace, setDocumentToReplace] = useState(null); + const [replacingFile, setReplacingFile] = useState(false); + const replaceFileInputRef = useRef(null); + + // Dropdown menu state + const [openDropdownId, setOpenDropdownId] = useState(null); + + // Helper function to get formatted quote number + const getQuoteNumber = (booking: CsvBookingResponse): string => { + return `#${booking.bookingId || booking.id.slice(0, 8).toUpperCase()}`; + }; + + // Get file extension and type + const getFileType = (fileName: string): string => { + const ext = fileName.split('.').pop()?.toLowerCase() || ''; + const typeMap: Record = { + pdf: 'PDF', + doc: 'Word', + docx: 'Word', + xls: 'Excel', + xlsx: 'Excel', + jpg: 'Image', + jpeg: 'Image', + png: 'Image', + gif: 'Image', + txt: 'Text', + csv: 'CSV', + }; + return typeMap[ext] || ext.toUpperCase(); + }; + + const fetchBookingsAndDocuments = useCallback(async () => { + try { + setLoading(true); + // Fetch all user's bookings (paginated, get all pages) + const response = await listCsvBookings({ page: 1, limit: 1000 }); + const allBookings = response.bookings || []; + setBookings(allBookings); + + // Extract all documents from all bookings + const allDocuments: DocumentWithBooking[] = []; + + allBookings.forEach((booking: CsvBookingResponse) => { + if (booking.documents && booking.documents.length > 0) { + booking.documents.forEach((doc: any, index: number) => { + // Use the correct field names from the backend + const actualFileName = doc.fileName || doc.name || 'document'; + const actualFilePath = doc.filePath || doc.url || ''; + const actualMimeType = doc.mimeType || doc.type || ''; + + // Extract clean file type from mimeType or fileName + let fileType = ''; + if (actualMimeType.includes('/')) { + const parts = actualMimeType.split('/'); + fileType = getFileType(parts[1]); + } else { + fileType = getFileType(actualFileName); + } + + allDocuments.push({ + id: doc.id || `${booking.id}-doc-${index}`, + fileName: actualFileName, + filePath: actualFilePath, + type: doc.type || '', + mimeType: actualMimeType, + size: doc.size || 0, + uploadedAt: doc.uploadedAt, + bookingId: booking.id, + quoteNumber: getQuoteNumber(booking), + route: `${booking.origin || 'N/A'} → ${booking.destination || 'N/A'}`, + status: booking.status, + carrierName: booking.carrierName || 'N/A', + fileType: fileType, + }); + }); + } + }); + + setDocuments(allDocuments); + setError(null); + } catch (err: any) { + setError(err.message || 'Erreur lors du chargement des documents'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchBookingsAndDocuments(); + }, [fetchBookingsAndDocuments]); + + // Filter documents + const filteredDocuments = documents.filter(doc => { + const matchesSearch = + searchTerm === '' || + (doc.fileName && doc.fileName.toLowerCase().includes(searchTerm.toLowerCase())) || + (doc.type && doc.type.toLowerCase().includes(searchTerm.toLowerCase())) || + doc.route.toLowerCase().includes(searchTerm.toLowerCase()) || + doc.carrierName.toLowerCase().includes(searchTerm.toLowerCase()); + + const matchesStatus = filterStatus === 'all' || doc.status === filterStatus; + + const matchesQuote = + filterQuoteNumber === '' || + doc.quoteNumber.toLowerCase().includes(filterQuoteNumber.toLowerCase()); + + return matchesSearch && matchesStatus && matchesQuote; + }); + + // Pagination + const totalPages = Math.ceil(filteredDocuments.length / itemsPerPage); + const startIndex = (currentPage - 1) * itemsPerPage; + const endIndex = startIndex + itemsPerPage; + const paginatedDocuments = filteredDocuments.slice(startIndex, endIndex); + + // Reset to page 1 when filters change + useEffect(() => { + setCurrentPage(1); + }, [searchTerm, filterStatus, filterQuoteNumber]); + + const getDocumentIcon = (type: string) => { + const typeLower = type.toLowerCase(); + const icons: Record = { + 'application/pdf': '📄', + 'image/jpeg': '🖼️', + 'image/png': '🖼️', + 'image/jpg': '🖼️', + pdf: '📄', + jpeg: '🖼️', + jpg: '🖼️', + png: '🖼️', + gif: '🖼️', + image: '🖼️', + word: '📝', + doc: '📝', + docx: '📝', + excel: '📊', + xls: '📊', + xlsx: '📊', + csv: '📊', + text: '📄', + txt: '📄', + }; + return icons[typeLower] || '📎'; + }; + + const getStatusColor = (status: string) => { + const colors: Record = { + PENDING: 'bg-yellow-100 text-yellow-800', + ACCEPTED: 'bg-green-100 text-green-800', + REJECTED: 'bg-red-100 text-red-800', + CANCELLED: 'bg-gray-100 text-gray-800', + }; + return colors[status] || 'bg-gray-100 text-gray-800'; + }; + + const getStatusLabel = (status: string) => { + const labels: Record = { + PENDING: 'En attente', + ACCEPTED: 'Accepté', + REJECTED: 'Refusé', + CANCELLED: 'Annulé', + }; + return labels[status] || status; + }; + + const handleDownload = async (url: string, fileName: string) => { + try { + // Try direct download first + const link = document.createElement('a'); + link.href = url; + link.download = fileName; + link.target = '_blank'; + link.setAttribute('download', fileName); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + // If direct download doesn't work, try fetch with blob + setTimeout(async () => { + try { + const response = await fetch(url, { + mode: 'cors', + credentials: 'include', + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const blob = await response.blob(); + const blobUrl = window.URL.createObjectURL(blob); + const link2 = document.createElement('a'); + link2.href = blobUrl; + link2.download = fileName; + document.body.appendChild(link2); + link2.click(); + document.body.removeChild(link2); + window.URL.revokeObjectURL(blobUrl); + } catch (fetchError) { + console.error('Fetch download failed:', fetchError); + } + }, 100); + } catch (error) { + console.error('Error downloading file:', error); + alert( + `Erreur lors du téléchargement du document: ${error instanceof Error ? error.message : 'Erreur inconnue'}` + ); + } + }; + + // Get unique bookings for add document modal + const bookingsWithPendingStatus = bookings.filter(b => b.status === 'PENDING'); + + const handleAddDocumentClick = () => { + setShowAddModal(true); + }; + + const handleCloseModal = () => { + setShowAddModal(false); + setSelectedBookingId(null); + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }; + + const handleFileUpload = async () => { + if (!selectedBookingId || !fileInputRef.current?.files?.length) { + alert('Veuillez sélectionner une réservation et au moins un fichier'); + return; + } + + setUploadingFiles(true); + try { + const formData = new FormData(); + const files = fileInputRef.current.files; + for (let i = 0; i < files.length; i++) { + formData.append('documents', files[i]); + } + + const token = localStorage.getItem('access_token'); + const response = await fetch( + `${process.env.NEXT_PUBLIC_API_URL}/api/v1/csv-bookings/${selectedBookingId}/documents`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + }, + body: formData, + } + ); + + if (!response.ok) { + throw new Error('Erreur lors de l\'ajout des documents'); + } + + alert('Documents ajoutés avec succès!'); + handleCloseModal(); + fetchBookingsAndDocuments(); // Refresh the list + } catch (error) { + console.error('Error uploading documents:', error); + alert( + `Erreur lors de l'ajout des documents: ${error instanceof Error ? error.message : 'Erreur inconnue'}` + ); + } finally { + setUploadingFiles(false); + } + }; + + // Toggle dropdown menu + const toggleDropdown = (docId: string) => { + setOpenDropdownId(openDropdownId === docId ? null : docId); + }; + + // Close dropdown when clicking outside + useEffect(() => { + const handleClickOutside = () => { + setOpenDropdownId(null); + }; + + if (openDropdownId) { + document.addEventListener('click', handleClickOutside); + return () => document.removeEventListener('click', handleClickOutside); + } + }, [openDropdownId]); + + // Replace document handlers + const handleReplaceClick = (doc: DocumentWithBooking) => { + setOpenDropdownId(null); + setDocumentToReplace(doc); + setShowReplaceModal(true); + }; + + const handleCloseReplaceModal = () => { + setShowReplaceModal(false); + setDocumentToReplace(null); + if (replaceFileInputRef.current) { + replaceFileInputRef.current.value = ''; + } + }; + + const handleReplaceDocument = async () => { + if (!documentToReplace || !replaceFileInputRef.current?.files?.length) { + alert('Veuillez sélectionner un fichier de remplacement'); + return; + } + + setReplacingFile(true); + try { + const formData = new FormData(); + formData.append('document', replaceFileInputRef.current.files[0]); + + const token = localStorage.getItem('access_token'); + const response = await fetch( + `${process.env.NEXT_PUBLIC_API_URL}/api/v1/csv-bookings/${documentToReplace.bookingId}/documents/${documentToReplace.id}`, + { + method: 'PATCH', + headers: { + Authorization: `Bearer ${token}`, + }, + body: formData, + } + ); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.message || 'Erreur lors du remplacement du document'); + } + + alert('Document remplacé avec succès!'); + handleCloseReplaceModal(); + fetchBookingsAndDocuments(); // Refresh the list + } catch (error) { + console.error('Error replacing document:', error); + alert( + `Erreur lors du remplacement: ${error instanceof Error ? error.message : 'Erreur inconnue'}` + ); + } finally { + setReplacingFile(false); + } + }; + + if (loading) { + return ( +
+
+
+

Chargement des documents...

+
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+

Mes Documents

+

+ Gérez tous les documents de vos réservations +

+
+ +
+ + {/* Stats */} +
+
+
Total Documents
+
{documents.length}
+
+
+
Réservations avec Documents
+
+ {bookings.filter(b => b.documents && b.documents.length > 0).length} +
+
+
+
Documents Filtrés
+
{filteredDocuments.length}
+
+
+ + {/* Filters */} +
+
+
+ + setSearchTerm(e.target.value)} + className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none" + /> +
+
+ + setFilterQuoteNumber(e.target.value)} + className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none" + /> +
+
+ + +
+
+
+ + {/* Error Message */} + {error && ( +
+ {error} +
+ )} + + {/* Documents Table */} +
+ + + + + + + + + + + + + + {paginatedDocuments.length === 0 ? ( + + + + ) : ( + paginatedDocuments.map((doc, index) => ( + + + + + + + + + + )) + )} + +
+ Nom du Document + + Type + + N° de Devis + + Route + + Transporteur + + Statut + + Actions +
+ {documents.length === 0 + ? 'Aucun document trouvé. Ajoutez des documents à vos réservations.' + : 'Aucun document ne correspond aux filtres sélectionnés.'} +
+
{doc.fileName}
+
+
+ + {getDocumentIcon(doc.fileType || doc.type)} + +
{doc.fileType || doc.type}
+
+
+
{doc.quoteNumber}
+
+
{doc.route}
+
+
{doc.carrierName}
+
+ + {getStatusLabel(doc.status)} + + +
+ + + {/* Dropdown Menu */} + {openDropdownId === `${doc.bookingId}-${doc.id}` && ( +
e.stopPropagation()} + > +
+ + +
+
+ )} +
+
+ + {/* Pagination Controls */} + {filteredDocuments.length > 0 && ( +
+
+ + +
+
+
+

+ Affichage de {startIndex + 1} à{' '} + + {Math.min(endIndex, filteredDocuments.length)} + {' '} + sur {filteredDocuments.length} résultats +

+
+
+
+ + +
+ +
+
+
+ )} +
+ + {/* Add Document Modal */} + {showAddModal && ( +
+
+ {/* Background overlay */} +
+ + {/* Modal panel */} +
+
+
+
+ + + +
+
+

+ Ajouter un document +

+
+
+ + +
+
+ + +

+ Formats acceptés: PDF, Word, Excel, Images (max 10 fichiers) +

+
+
+
+
+
+
+ + +
+
+
+
+ )} + + {/* Replace Document Modal */} + {showReplaceModal && documentToReplace && ( +
+
+ {/* Background overlay */} +
+ + {/* Modal panel */} +
+
+
+
+ + + +
+
+

+ Remplacer le document +

+
+ {/* Current document info */} +
+

Document actuel:

+

+ {documentToReplace.fileName} +

+

+ Réservation: {documentToReplace.quoteNumber} - {documentToReplace.route} +

+
+ +
+ + +

+ Formats acceptés: PDF, Word, Excel, Images +

+
+
+
+
+
+
+ + +
+
+
+
+ )} +
+ ); +} diff --git a/apps/frontend/app/dashboard/layout.tsx b/apps/frontend/app/dashboard/layout.tsx index 50872ae..3c6779e 100644 --- a/apps/frontend/app/dashboard/layout.tsx +++ b/apps/frontend/app/dashboard/layout.tsx @@ -24,6 +24,9 @@ export default function DashboardLayout({ children }: { children: React.ReactNod const navigation = [ { name: 'Dashboard', href: '/dashboard', icon: '📊' }, { name: 'Bookings', href: '/dashboard/bookings', icon: '📦' }, + { name: 'Documents', href: '/dashboard/documents', icon: '📄' }, + { name: 'Track & Trace', href: '/dashboard/track-trace', icon: '🔍' }, + { name: 'Wiki', href: '/dashboard/wiki', icon: '📚' }, { name: 'My Profile', href: '/dashboard/profile', icon: '👤' }, { name: 'Organization', href: '/dashboard/settings/organization', icon: '🏢' }, // ADMIN and MANAGER only navigation items diff --git a/apps/frontend/app/dashboard/settings/organization/page.tsx b/apps/frontend/app/dashboard/settings/organization/page.tsx index 993b406..f376da3 100644 --- a/apps/frontend/app/dashboard/settings/organization/page.tsx +++ b/apps/frontend/app/dashboard/settings/organization/page.tsx @@ -1,9 +1,12 @@ 'use client'; import { useEffect, useState, useCallback } from 'react'; +import { useSearchParams } from 'next/navigation'; import { useAuth } from '@/lib/context/auth-context'; import { getOrganization, updateOrganization } from '@/lib/api/organizations'; import type { OrganizationResponse } from '@/types/api'; +import SubscriptionTab from '@/components/organization/SubscriptionTab'; +import LicensesTab from '@/components/organization/LicensesTab'; interface OrganizationForm { name: string; @@ -17,11 +20,21 @@ interface OrganizationForm { address_country: string; } -type TabType = 'information' | 'address'; +type TabType = 'information' | 'address' | 'subscription' | 'licenses'; export default function OrganizationSettingsPage() { const { user } = useAuth(); + const searchParams = useSearchParams(); const [activeTab, setActiveTab] = useState('information'); + + // Auto-switch to subscription tab if coming back from Stripe + useEffect(() => { + const isSuccess = searchParams.get('success') === 'true'; + const isCanceled = searchParams.get('canceled') === 'true'; + if (isSuccess || isCanceled) { + setActiveTab('subscription'); + } + }, [searchParams]); const [organization, setOrganization] = useState(null); const [formData, setFormData] = useState({ name: '', @@ -152,16 +165,56 @@ export default function OrganizationSettingsPage() { ); } + const tabs = [ + { + id: 'information' as TabType, + label: 'Informations', + icon: ( + + + + ), + }, + { + id: 'address' as TabType, + label: 'Adresse', + icon: ( + + + + + ), + }, + { + id: 'subscription' as TabType, + label: 'Abonnement', + icon: ( + + + + ), + }, + { + id: 'licenses' as TabType, + label: 'Licences', + icon: ( + + + + ), + }, + ]; + return (
{/* Header */}
-

Paramètres de l'organisation

+

Paramètres de l'organisation

Gérez les informations de votre organisation

{/* Success Message */} - {successMessage && ( + {successMessage && (activeTab === 'information' || activeTab === 'address') && (
@@ -173,7 +226,7 @@ export default function OrganizationSettingsPage() { )} {/* Error Message */} - {error && ( + {error && (activeTab === 'information' || activeTab === 'address') && (
@@ -185,13 +238,13 @@ export default function OrganizationSettingsPage() { )} {/* Read-only warning for USER role */} - {!canEdit && ( + {!canEdit && (activeTab === 'information' || activeTab === 'address') && (
-

Mode lecture seule - Seuls les administrateurs et managers peuvent modifier l'organisation

+

Mode lecture seule - Seuls les administrateurs et managers peuvent modifier l'organisation

)} @@ -199,38 +252,23 @@ export default function OrganizationSettingsPage() { {/* Tabs */}
-
@@ -258,7 +296,7 @@ export default function OrganizationSettingsPage() {
)} + + {activeTab === 'subscription' && } + + {activeTab === 'licenses' && }
- {/* Actions */} - {canEdit && ( + {/* Actions (only for information and address tabs) */} + {canEdit && (activeTab === 'information' || activeTab === 'address') && (
+ {licenseStatus?.canInvite ? ( + + ) : ( + + + + Upgrade to Invite + + )}
{success && ( @@ -319,13 +389,23 @@ export default function UsersManagementPage() {

No users

Get started by inviting a team member

- + {licenseStatus?.canInvite ? ( + + ) : ( + + + + Upgrade to Invite + + )}
)} diff --git a/apps/frontend/app/dashboard/track-trace/page.tsx b/apps/frontend/app/dashboard/track-trace/page.tsx new file mode 100644 index 0000000..143d759 --- /dev/null +++ b/apps/frontend/app/dashboard/track-trace/page.tsx @@ -0,0 +1,287 @@ +/** + * Track & Trace Page + * + * Allows users to track their shipments by entering tracking numbers + * and selecting the carrier. Redirects to carrier's tracking page. + */ + +'use client'; + +import { useState } from 'react'; +import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; + +// Carrier tracking URLs - the tracking number will be appended +const carriers = [ + { + id: 'maersk', + name: 'Maersk', + logo: '🚢', + trackingUrl: 'https://www.maersk.com/tracking/', + placeholder: 'Ex: MSKU1234567', + description: 'Container or B/L number', + }, + { + id: 'msc', + name: 'MSC', + logo: '🛳️', + trackingUrl: 'https://www.msc.com/track-a-shipment?query=', + placeholder: 'Ex: MSCU1234567', + description: 'Container, B/L or Booking number', + }, + { + id: 'cma-cgm', + name: 'CMA CGM', + logo: '⚓', + trackingUrl: 'https://www.cma-cgm.com/ebusiness/tracking/search?SearchBy=Container&Reference=', + placeholder: 'Ex: CMAU1234567', + description: 'Container or B/L number', + }, + { + id: 'hapag-lloyd', + name: 'Hapag-Lloyd', + logo: '🔷', + trackingUrl: 'https://www.hapag-lloyd.com/en/online-business/track/track-by-container-solution.html?container=', + placeholder: 'Ex: HLCU1234567', + description: 'Container number', + }, + { + id: 'cosco', + name: 'COSCO', + logo: '🌊', + trackingUrl: 'https://elines.coscoshipping.com/ebusiness/cargoTracking?trackingNumber=', + placeholder: 'Ex: COSU1234567', + description: 'Container or B/L number', + }, + { + id: 'one', + name: 'ONE (Ocean Network Express)', + logo: '🟣', + trackingUrl: 'https://ecomm.one-line.com/one-ecom/manage-shipment/cargo-tracking?trkNoParam=', + placeholder: 'Ex: ONEU1234567', + description: 'Container or B/L number', + }, + { + id: 'evergreen', + name: 'Evergreen', + logo: '🌲', + trackingUrl: 'https://www.shipmentlink.com/servlet/TDB1_CargoTracking.do?BL=', + placeholder: 'Ex: EGHU1234567', + description: 'Container or B/L number', + }, + { + id: 'yangming', + name: 'Yang Ming', + logo: '🟡', + trackingUrl: 'https://www.yangming.com/e-service/Track_Trace/track_trace_cargo_tracking.aspx?rdolType=CT&str=', + placeholder: 'Ex: YMLU1234567', + description: 'Container number', + }, + { + id: 'zim', + name: 'ZIM', + logo: '🔵', + trackingUrl: 'https://www.zim.com/tools/track-a-shipment?consnumber=', + placeholder: 'Ex: ZIMU1234567', + description: 'Container or B/L number', + }, + { + id: 'hmm', + name: 'HMM (Hyundai)', + logo: '🟠', + trackingUrl: 'https://www.hmm21.com/cms/business/ebiz/trackTrace/trackTrace/index.jsp?type=1&number=', + placeholder: 'Ex: HDMU1234567', + description: 'Container or B/L number', + }, +]; + +export default function TrackTracePage() { + const [trackingNumber, setTrackingNumber] = useState(''); + const [selectedCarrier, setSelectedCarrier] = useState(''); + const [error, setError] = useState(''); + + const handleTrack = () => { + // Validation + if (!trackingNumber.trim()) { + setError('Veuillez entrer un numéro de tracking'); + return; + } + if (!selectedCarrier) { + setError('Veuillez sélectionner un transporteur'); + return; + } + + setError(''); + + // Find the carrier and build the tracking URL + const carrier = carriers.find(c => c.id === selectedCarrier); + if (carrier) { + const trackingUrl = carrier.trackingUrl + encodeURIComponent(trackingNumber.trim()); + // Open in new tab + window.open(trackingUrl, '_blank', 'noopener,noreferrer'); + } + }; + + const handleKeyPress = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + handleTrack(); + } + }; + + const selectedCarrierData = carriers.find(c => c.id === selectedCarrier); + + return ( +
+ {/* Header */} +
+

Track & Trace

+

+ Suivez vos expéditions en temps réel. Entrez votre numéro de tracking et sélectionnez le transporteur. +

+
+ + {/* Search Form */} + + + + 🔍 + Rechercher une expédition + + + Entrez votre numéro de conteneur, connaissement (B/L) ou référence de booking + + + + {/* Carrier Selection */} +
+ +
+ {carriers.map(carrier => ( + + ))} +
+
+ + {/* Tracking Number Input */} +
+ +
+
+ { + setTrackingNumber(e.target.value.toUpperCase()); + setError(''); + }} + onKeyPress={handleKeyPress} + placeholder={selectedCarrierData?.placeholder || 'Ex: MSKU1234567'} + className="text-lg font-mono border-gray-300 focus:border-blue-500" + /> + {selectedCarrierData && ( +

{selectedCarrierData.description}

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

{error}

+
+ )} +
+
+ + {/* Help Section */} +
+ + + + 📦 + Numéro de conteneur + + + +

+ Format standard: 4 lettres + 7 chiffres (ex: MSKU1234567). + Le préfixe indique généralement le propriétaire du conteneur. +

+
+
+ + + + + 📋 + Connaissement (B/L) + + + +

+ Le numéro de Bill of Lading est fourni par le transporteur lors de la confirmation de booking. + Format variable selon le carrier. +

+
+
+ + + + + 📝 + Référence de booking + + + +

+ Numéro de réservation attribué par le transporteur lors de la réservation initiale de l'espace sur le navire. +

+
+
+
+ + {/* Info Box */} +
+
+ 💡 +
+

Comment fonctionne le suivi ?

+

+ Cette fonctionnalité vous redirige vers le site officiel du transporteur pour obtenir les informations + de suivi les plus récentes. Les données affichées proviennent directement du système du transporteur. +

+
+
+
+
+ ); +} diff --git a/apps/frontend/app/dashboard/wiki/assurance/page.tsx b/apps/frontend/app/dashboard/wiki/assurance/page.tsx new file mode 100644 index 0000000..a9c7a8c --- /dev/null +++ b/apps/frontend/app/dashboard/wiki/assurance/page.tsx @@ -0,0 +1,210 @@ +/** + * Assurance Maritime - Wiki Page + * + * Protection des marchandises en transit + */ + +import Link from 'next/link'; +import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; + +const clausesICC = [ + { + code: 'ICC A', + name: 'All Risks', + coverage: 'Tous risques', + description: 'Couverture la plus complète. Couvre tous les risques de perte ou dommage sauf exclusions spécifiques.', + includes: ['Vol', 'Casse', 'Mouille', 'Manquants', 'Chute', 'Écrasement'], + excludes: ['Vice propre', 'Emballage insuffisant', 'Guerre', 'Grèves'], + recommended: true, + }, + { + code: 'ICC B', + name: 'With Average', + coverage: 'Risques majeurs', + description: 'Couverture intermédiaire incluant les événements majeurs de transport.', + includes: ['Incendie', 'Naufrage', 'Échouement', 'Collision', 'Jet à la mer', 'Avarie commune'], + excludes: ['Vol', 'Mouille', 'Manquants (hors avarie commune)', 'Casse isolée'], + recommended: false, + }, + { + code: 'ICC C', + name: 'Free of Particular Average', + coverage: 'Risques minimaux', + description: 'Couverture de base pour les sinistres majeurs uniquement.', + includes: ['Incendie', 'Naufrage', 'Échouement', 'Collision', 'Avarie commune'], + excludes: ['Vol', 'Mouille', 'Casse', 'Manquants', 'Perte partielle'], + recommended: false, + }, +]; + +const extensionsGaranties = [ + { name: 'Guerre et grèves', description: 'Extension pour couvrir les risques de guerre, grèves, émeutes.' }, + { name: 'Magasin à magasin', description: 'Couverture étendue incluant les phases de stockage.' }, + { name: 'Frais de réexpédition', description: 'Couvre les frais en cas de changement de destination.' }, + { name: 'Pertes financières', description: 'Perte de marge, frais supplémentaires liés au sinistre.' }, + { name: 'Transport frigorifique', description: 'Risques spécifiques liés au froid (panne, variation).' }, +]; + +export default function AssurancePage() { + return ( +
+ {/* Header with back link */} +
+ + + + + Retour au Wiki + +
+ + {/* Title */} +
+
+ 🛡️ +

Assurance Maritime (Cargo Insurance)

+
+

+ L'assurance transport maritime protège les marchandises contre les risques de perte ou de dommage + pendant le transit. Elle est régie par les Institute Cargo Clauses (ICC) de l'Institute of London Underwriters. +

+
+ + {/* Why insure */} + + +

Pourquoi s'assurer ?

+
    +
  • Responsabilité limitée du transporteur : Maximum ~2 DTS/kg (Convention de Bruxelles)
  • +
  • Délai de réclamation court : 3 jours pour les réserves au transporteur
  • +
  • Preuves difficiles : Charge de la preuve souvent sur l'expéditeur
  • +
  • Exigence bancaire : Souvent requise pour les lettres de crédit (CIF, CIP)
  • +
+
+
+ + {/* ICC Clauses */} +
+

📋 Clauses ICC (Institute Cargo Clauses)

+
+ {clausesICC.map((clause) => ( + + + + + {clause.code} + + {clause.name} + {clause.recommended && ( + Recommandé + )} + + + +

{clause.description}

+
+
+

✓ Couvert

+
    + {clause.includes.map((item) => ( +
  • + + {item} +
  • + ))} +
+
+
+

✗ Non couvert

+
    + {clause.excludes.map((item) => ( +
  • + + {item} +
  • + ))} +
+
+
+
+
+ ))} +
+
+ + {/* Valeur assurée */} + + +

💰 Calcul de la Valeur Assurée

+
+
+

+ Valeur assurée = (CIF + 10%) × Taux de change +

+
+
+
+

CIF

+

Coût + Assurance + Fret jusqu'au port de destination

+
+
+

+ 10%

+

Majoration standard pour couvrir le profit espéré

+
+
+

Taux de prime

+

0.1% à 1% selon marchandise, trajet, clause

+
+
+
+
+
+ + {/* Extensions */} +
+

➕ Extensions de Garantie

+
+ {extensionsGaranties.map((ext) => ( + + +

{ext.name}

+

{ext.description}

+
+
+ ))} +
+
+ + {/* Process */} + + +

📝 En Cas de Sinistre

+
    +
  1. Constater : Émettre des réserves précises sur le bon de livraison
  2. +
  3. Préserver : Ne pas modifier l'état des marchandises (photos, témoins)
  4. +
  5. Notifier : Informer l'assureur sous 5 jours ouvrés
  6. +
  7. Documenter : Rassembler tous les documents (B/L, facture, expertise)
  8. +
  9. Réclamer : Déposer une réclamation formelle avec justificatifs
  10. +
+
+
+ + {/* Tips */} + + +

💡 Conseils Pratiques

+
    +
  • Toujours opter pour ICC A (All Risks) sauf marchandises très résistantes
  • +
  • Vérifier les exclusions et souscrire les extensions nécessaires
  • +
  • Photographier la marchandise avant expédition
  • +
  • Conserver tous les documents originaux
  • +
  • Ne jamais signer "reçu conforme" sans avoir vérifié
  • +
+
+
+
+ ); +} diff --git a/apps/frontend/app/dashboard/wiki/calcul-fret/page.tsx b/apps/frontend/app/dashboard/wiki/calcul-fret/page.tsx new file mode 100644 index 0000000..f9c01bc --- /dev/null +++ b/apps/frontend/app/dashboard/wiki/calcul-fret/page.tsx @@ -0,0 +1,269 @@ +/** + * Calcul du Fret Maritime - Wiki Page + * + * Comment sont calculés les coûts de transport + */ + +import Link from 'next/link'; +import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; + +const surcharges = [ + { + code: 'BAF', + name: 'Bunker Adjustment Factor', + description: 'Surcharge carburant liée aux fluctuations du prix du fuel maritime.', + variation: 'Mensuelle', + }, + { + code: 'CAF', + name: 'Currency Adjustment Factor', + description: 'Ajustement monétaire pour compenser les variations de taux de change.', + variation: 'Mensuelle', + }, + { + code: 'THC', + name: 'Terminal Handling Charges', + description: 'Frais de manutention au terminal portuaire (chargement/déchargement).', + variation: 'Par port', + }, + { + code: 'ISPS', + name: 'International Ship & Port Security', + description: 'Surcharge sécurité conforme au code ISPS (post 11 septembre).', + variation: 'Fixe', + }, + { + code: 'LSS', + name: 'Low Sulphur Surcharge', + description: 'Surcharge carburant bas soufre (réglementation IMO 2020).', + variation: 'Mensuelle', + }, + { + code: 'PSS', + name: 'Peak Season Surcharge', + description: 'Surcharge haute saison (généralement août-octobre vers l\'Europe).', + variation: 'Saisonnière', + }, + { + code: 'GRI', + name: 'General Rate Increase', + description: 'Augmentation générale des tarifs, généralement annoncée à l\'avance.', + variation: 'Variable', + }, + { + code: 'EBS', + name: 'Emergency Bunker Surcharge', + description: 'Surcharge d\'urgence en cas de hausse brutale du carburant.', + variation: 'Exceptionnelle', + }, +]; + +const fraisAdditionnels = [ + { name: 'Documentation Fee', description: 'Frais d\'émission du B/L ou autres documents', typical: '35-75 USD' }, + { name: 'Seal Fee', description: 'Coût du plomb de sécurité du conteneur', typical: '10-25 USD' }, + { name: 'VGM Fee', description: 'Frais de certification du poids vérifié', typical: '25-50 USD' }, + { name: 'Container Cleaning', description: 'Nettoyage du conteneur si requis', typical: '50-150 USD' }, + { name: 'Customs Clearance', description: 'Frais de dédouanement (honoraires)', typical: '50-200 USD' }, + { name: 'Inspection Fee', description: 'Frais d\'inspection (scanner, phytosanitaire)', typical: '50-300 USD' }, +]; + +export default function CalculFretPage() { + return ( +
+ {/* Header with back link */} +
+ + + + + Retour au Wiki + +
+ + {/* Title */} +
+
+ 🧮 +

Calcul du Fret Maritime

+
+

+ Le coût du transport maritime se compose du fret de base et de nombreuses surcharges. + Comprendre ces éléments est essentiel pour bien estimer ses coûts logistiques. +

+
+ + {/* Base Calculation */} + + +

📐 Principes de Base

+
+
+

FCL (Conteneur Complet)

+

+ Prix forfaitaire par conteneur (20', 40', 40'HC), indépendant du poids (dans les limites). +

+

Ex: 1,500 USD/20' Shanghai → Rotterdam

+
+
+

LCL (Groupage)

+

+ Prix au mètre cube (CBM) ou à la tonne, selon le plus avantageux pour le transporteur. +

+

Ex: 45 USD/CBM (minimum 1 CBM)

+
+
+
+
+ + {/* Weight Calculation */} + + +

⚖️ Poids Taxable (LCL)

+
+
+

+ Poids taxable = MAX (Volume CBM × 1000, Poids brut kg) +

+
+
+
+
+

Exemple 1 : Marchandise légère

+
    +
  • Volume : 2 CBM
  • +
  • Poids : 500 kg
  • +
  • Volume équivalent : 2 × 1000 = 2000 kg
  • +
  • → Taxé sur 2 CBM (ou 2 tonnes)
  • +
+
+
+

Exemple 2 : Marchandise lourde

+
    +
  • Volume : 1 CBM
  • +
  • Poids : 2000 kg
  • +
  • Volume équivalent : 1 × 1000 = 1000 kg
  • +
  • → Taxé sur 2 tonnes (poids réel)
  • +
+
+
+

+ Ratio standard : 1 CBM = 1 tonne (1000 kg). Certaines compagnies utilisent 1 CBM = 333 kg pour l'aérien. +

+
+
+ + {/* Surcharges */} +
+

📋 Surcharges Courantes

+
+ {surcharges.map((sur) => ( + + + + + {sur.code} + + {sur.name} + + + +

{sur.description}

+

Variation : {sur.variation}

+
+
+ ))} +
+
+ + {/* Additional fees */} +
+

💵 Frais Additionnels

+ + +
+ + + + + + + + + + {fraisAdditionnels.map((frais) => ( + + + + + + ))} + +
FraisDescriptionMontant typique
{frais.name}{frais.description}{frais.typical}
+
+
+
+
+ + {/* Example calculation */} + + +

📊 Exemple de Devis FCL

+
+

Conteneur 40' Shanghai → Le Havre

+
+
+ Ocean Freight (base) + 1,800 USD +
+
+ BAF + 450 USD +
+
+ LSS + 180 USD +
+
+ THC Origin + 150 USD +
+
+ THC Destination + 280 EUR +
+
+ ISPS + 12 USD +
+
+ Documentation + 45 USD +
+
+ Total estimé + ~2,637 USD + 280 EUR +
+
+
+
+
+ + {/* Tips */} + + +

💡 Conseils pour Optimiser

+
    +
  • Demandez des devis "All-in" pour éviter les surprises de surcharges
  • +
  • Comparez les transitaires sur le total, pas seulement le fret de base
  • +
  • Anticipez la haute saison (septembre-novembre) avec des réservations précoces
  • +
  • Optimisez le remplissage des conteneurs pour réduire le coût unitaire
  • +
  • Vérifiez les surcharges qui peuvent changer entre devis et facture
  • +
+
+
+
+ ); +} diff --git a/apps/frontend/app/dashboard/wiki/conteneurs/page.tsx b/apps/frontend/app/dashboard/wiki/conteneurs/page.tsx new file mode 100644 index 0000000..ddcd6fd --- /dev/null +++ b/apps/frontend/app/dashboard/wiki/conteneurs/page.tsx @@ -0,0 +1,315 @@ +/** + * Conteneurs et Types de Cargo - Wiki Page + * + * Complete guide to container types + */ + +import Link from 'next/link'; +import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; + +const containers = [ + { + type: '20\' Standard (20\' DRY)', + code: '20DC', + dimensions: { + external: '6.06m x 2.44m x 2.59m', + internal: '5.90m x 2.35m x 2.39m', + door: '2.34m x 2.28m', + }, + capacity: { + volume: '33 m³', + payload: '28,200 kg', + tare: '2,300 kg', + }, + usage: 'Marchandises générales sèches', + icon: '📦', + }, + { + type: '40\' Standard (40\' DRY)', + code: '40DC', + dimensions: { + external: '12.19m x 2.44m x 2.59m', + internal: '12.03m x 2.35m x 2.39m', + door: '2.34m x 2.28m', + }, + capacity: { + volume: '67 m³', + payload: '26,680 kg', + tare: '3,800 kg', + }, + usage: 'Marchandises générales, cargo volumineux', + icon: '📦', + }, + { + type: '40\' High Cube (40\' HC)', + code: '40HC', + dimensions: { + external: '12.19m x 2.44m x 2.90m', + internal: '12.03m x 2.35m x 2.69m', + door: '2.34m x 2.58m', + }, + capacity: { + volume: '76 m³', + payload: '26,460 kg', + tare: '4,020 kg', + }, + usage: 'Cargo léger mais volumineux', + icon: '📦', + }, + { + type: 'Reefer (Réfrigéré)', + code: '20RF / 40RF', + dimensions: { + external: 'Comme standard', + internal: 'Légèrement réduit (isolation)', + door: 'Standard', + }, + capacity: { + volume: '28 m³ (20\') / 60 m³ (40\')', + payload: '27,400 kg / 26,500 kg', + temperature: '-30°C à +30°C', + }, + usage: 'Produits périssables, pharmaceutiques', + icon: '❄️', + }, + { + type: 'Open Top', + code: '20OT / 40OT', + dimensions: { + external: 'Comme standard', + internal: 'Comme standard', + door: 'Toit amovible + portes arrière', + }, + capacity: { + volume: 'Comme standard', + payload: '28,100 kg / 26,400 kg', + tare: '2,400 kg / 4,100 kg', + }, + usage: 'Cargo hors gabarit en hauteur, machinerie', + icon: '📭', + }, + { + type: 'Flat Rack', + code: '20FR / 40FR', + dimensions: { + external: 'Comme standard (sans parois)', + internal: 'Plateau sans côtés', + door: 'N/A', + }, + capacity: { + volume: 'N/A', + payload: '31,000 kg / 40,000 kg', + tare: '2,700 kg / 4,700 kg', + }, + usage: 'Cargo très lourd ou surdimensionné', + icon: '🚛', + }, + { + type: 'Tank Container', + code: '20TK', + dimensions: { + external: 'Cadre standard 20\'', + internal: 'Citerne 21,000-26,000 L', + door: 'Valves et trappes', + }, + capacity: { + volume: '21,000-26,000 litres', + payload: '26,000 kg', + tare: '3,500 kg', + }, + usage: 'Liquides, gaz, produits chimiques', + icon: '🛢️', + }, +]; + +const specialEquipment = [ + { name: 'Flexitank', desc: 'Poche flexible pour liquides non dangereux dans un 20\' standard', capacity: '16,000-24,000 L' }, + { name: 'Garment on Hanger (GOH)', desc: 'Barres pour vêtements suspendus', capacity: 'Variable' }, + { name: 'Ventilated Container', desc: 'Aération naturelle pour café, cacao, oignons', capacity: 'Standard' }, + { name: 'Insulated Container', desc: 'Isolation thermique sans réfrigération active', capacity: 'Standard' }, +]; + +export default function ConteneursPage() { + return ( +
+ {/* Header with back link */} +
+ + + + + Retour au Wiki + +
+ + {/* Title */} +
+
+ 📦 +

Conteneurs et Types de Cargo

+
+

+ Les conteneurs maritimes sont standardisés selon les normes ISO. Comprendre les différents + types permet de choisir le conteneur adapté à votre marchandise et d'optimiser les coûts. +

+
+ + {/* Quick Reference */} + + +

Codes ISO Courants

+
+ {[ + { code: '20DC', name: '20\' Dry' }, + { code: '40DC', name: '40\' Dry' }, + { code: '40HC', name: '40\' High Cube' }, + { code: '45HC', name: '45\' High Cube' }, + { code: '20RF', name: '20\' Reefer' }, + { code: '40RF', name: '40\' Reefer' }, + { code: '20OT', name: '20\' Open Top' }, + { code: '20FR', name: '20\' Flat Rack' }, + ].map((item) => ( +
+ {item.code} +

{item.name}

+
+ ))} +
+
+
+ + {/* Container Types */} +

Types de Conteneurs

+
+ {containers.map((container) => ( + + + +
+ {container.icon} +
+ {container.type} + + {container.code} + +
+
+
+
+ +

{container.usage}

+
+
+

Dimensions

+
    +
  • + Externe: + {container.dimensions.external} +
  • +
  • + Interne: + {container.dimensions.internal} +
  • +
  • + Porte: + {container.dimensions.door} +
  • +
+
+
+

Capacité

+
    + {Object.entries(container.capacity).map(([key, value]) => ( +
  • + {key}: + {value} +
  • + ))} +
+
+
+
+
+ ))} +
+ + {/* Special Equipment */} +

Équipements Spéciaux

+ + +
+ {specialEquipment.map((equip) => ( +
+

{equip.name}

+

{equip.desc}

+

Capacité: {equip.capacity}

+
+ ))} +
+
+
+ + {/* Container Selection Guide */} + + + Guide de Sélection + + +
+
+ 📦 +
+

Marchandises générales

+

→ 20' ou 40' Standard (DRY)

+
+
+
+ ❄️ +
+

Produits réfrigérés/congelés

+

→ Reefer 20' ou 40'

+
+
+
+ 📭 +
+

Cargo hors gabarit (hauteur)

+

→ Open Top

+
+
+
+ 🚛 +
+

Machinerie lourde/surdimensionnée

+

→ Flat Rack

+
+
+
+ 🛢️ +
+

Liquides en vrac

+

→ Tank Container ou Flexitank

+
+
+
+
+
+ + {/* Tips */} + + +

Conseils Pratiques

+
    +
  • Un 40' HC offre 30% de volume en plus qu'un 20' mais coûte rarement le double
  • +
  • Les Reefer nécessitent une alimentation électrique au port et sur le navire
  • +
  • Les conteneurs spéciaux (OT, FR, Tank) ont une disponibilité limitée - réservez à l'avance
  • +
  • Vérifiez le poids maximum autorisé sur les routes du pays de destination
  • +
+
+
+
+ ); +} diff --git a/apps/frontend/app/dashboard/wiki/documents-transport/page.tsx b/apps/frontend/app/dashboard/wiki/documents-transport/page.tsx new file mode 100644 index 0000000..416f316 --- /dev/null +++ b/apps/frontend/app/dashboard/wiki/documents-transport/page.tsx @@ -0,0 +1,249 @@ +/** + * Documents de Transport Maritime - Wiki Page + * + * Essential documents for maritime shipping + */ + +import Link from 'next/link'; +import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; + +const documents = [ + { + name: 'Bill of Lading (B/L)', + french: 'Connaissement', + description: 'Document principal du transport maritime. Il fait preuve du contrat de transport, accuse réception des marchandises et constitue un titre de propriété négociable.', + types: [ + { name: 'B/L à ordre', desc: 'Négociable, peut être endossé' }, + { name: 'B/L nominatif', desc: 'Au nom d\'un destinataire précis' }, + { name: 'B/L au porteur', desc: 'Propriété à celui qui le détient' }, + ], + importance: 'Critique', + icon: '📄', + }, + { + name: 'Sea Waybill', + french: 'Lettre de transport maritime', + description: 'Document non négociable servant de preuve du contrat de transport et de reçu. Plus simple que le B/L car pas besoin de présenter l\'original pour retirer les marchandises.', + types: [ + { name: 'Standard', desc: 'Pour expéditions entre parties de confiance' }, + { name: 'Express', desc: 'Libération rapide sans documents originaux' }, + ], + importance: 'Important', + icon: '📋', + }, + { + name: 'Manifest', + french: 'Manifeste de cargaison', + description: 'Liste complète de toutes les marchandises chargées à bord d\'un navire. Utilisé par les autorités douanières et portuaires.', + types: [ + { name: 'Cargo Manifest', desc: 'Liste détaillée des marchandises' }, + { name: 'Freight Manifest', desc: 'Inclut les informations de fret' }, + ], + importance: 'Obligatoire', + icon: '📑', + }, + { + name: 'Packing List', + french: 'Liste de colisage', + description: 'Document détaillant le contenu de chaque colis, ses dimensions et son poids. Essentiel pour le dédouanement.', + types: [ + { name: 'Simple', desc: 'Liste basique des contenus' }, + { name: 'Détaillée', desc: 'Avec poids, dimensions, marquage' }, + ], + importance: 'Important', + icon: '📦', + }, + { + name: 'Commercial Invoice', + french: 'Facture commerciale', + description: 'Facture établie par le vendeur décrivant les marchandises, leur valeur et les conditions de vente. Base pour le calcul des droits de douane.', + types: [ + { name: 'Proforma', desc: 'Avant expédition, pour cotation' }, + { name: 'Définitive', desc: 'Document final de facturation' }, + ], + importance: 'Critique', + icon: '🧾', + }, + { + name: 'Certificate of Origin', + french: 'Certificat d\'origine', + description: 'Document certifiant le pays de fabrication ou de transformation des marchandises. Requis pour les préférences tarifaires.', + types: [ + { name: 'EUR.1', desc: 'Pour les échanges UE' }, + { name: 'Form A', desc: 'Système de préférences généralisées' }, + { name: 'Non préférentiel', desc: 'Attestation simple d\'origine' }, + ], + importance: 'Selon destination', + icon: '🏭', + }, +]; + +const additionalDocs = [ + { + name: 'Dangerous Goods Declaration', + description: 'Obligatoire pour les marchandises dangereuses (IMDG)', + }, + { + name: 'Phytosanitary Certificate', + description: 'Pour les produits végétaux et alimentaires', + }, + { + name: 'Insurance Certificate', + description: 'Preuve de couverture d\'assurance cargo', + }, + { + name: 'Inspection Certificate', + description: 'Rapport de contrôle qualité avant embarquement', + }, + { + name: 'VGM Declaration', + description: 'Déclaration du poids vérifié du conteneur (SOLAS)', + }, +]; + +export default function DocumentsTransportPage() { + return ( +
+ {/* Header with back link */} +
+ + + + + Retour au Wiki + +
+ + {/* Title */} +
+
+ 📋 +

Documents de Transport Maritime

+
+

+ Le transport maritime international nécessite une documentation précise et complète. + Ces documents servent de preuve de propriété, de contrat de transport et sont essentiels + pour le passage en douane. +

+
+ + {/* Importance Warning */} + + +

Important

+

+ Une erreur ou un manque de document peut entraîner des retards considérables, des frais + de surestaries (demurrage) et même le refus d'entrée des marchandises dans le pays de destination. +

+
+
+ + {/* Main Documents */} +

Documents Principaux

+
+ {documents.map((doc) => ( + + + +
+ {doc.icon} +
+ {doc.name} + ({doc.french}) +
+
+ + {doc.importance} + +
+
+ +

{doc.description}

+
+

Types:

+
    + {doc.types.map((type) => ( +
  • + {type.name}: + {type.desc} +
  • + ))} +
+
+
+
+ ))} +
+ + {/* Bill of Lading Detail */} + + + Focus: Le Bill of Lading (B/L) + + +

+ Le B/L remplit trois fonctions essentielles: +

+
+
+

1. Reçu

+

+ Confirme que le transporteur a reçu les marchandises dans l'état décrit +

+
+
+

2. Contrat

+

+ Preuve du contrat de transport entre le chargeur et le transporteur +

+
+
+

3. Titre

+

+ Document de titre permettant le transfert de propriété des marchandises +

+
+
+
+
+ + {/* Additional Documents */} +

Documents Complémentaires

+ + +
+ {additionalDocs.map((doc) => ( +
+ +
+

{doc.name}

+

{doc.description}

+
+
+ ))} +
+
+
+ + {/* Tips */} + + +

Conseils Pratiques

+
    +
  • Vérifiez la cohérence entre tous les documents (noms, adresses, descriptions)
  • +
  • Conservez des copies de tous les originaux
  • +
  • Anticipez les délais d'obtention des certificats (origine, sanitaire, etc.)
  • +
  • En cas de L/C, les documents doivent correspondre exactement aux exigences
  • +
+
+
+
+ ); +} diff --git a/apps/frontend/app/dashboard/wiki/douanes/page.tsx b/apps/frontend/app/dashboard/wiki/douanes/page.tsx new file mode 100644 index 0000000..d119438 --- /dev/null +++ b/apps/frontend/app/dashboard/wiki/douanes/page.tsx @@ -0,0 +1,218 @@ +/** + * Procédures Douanières - Wiki Page + * + * Guide des formalités douanières import/export + */ + +import Link from 'next/link'; +import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; + +const regimesDouaniers = [ + { + code: 'IM4', + name: 'Mise en libre pratique', + description: 'Importation définitive avec paiement des droits et taxes. La marchandise entre dans le circuit économique.', + }, + { + code: 'IM5', + name: 'Admission temporaire', + description: 'Importation temporaire avec suspension des droits. Pour les marchandises réexportées en l\'état.', + }, + { + code: 'IM6', + name: 'Perfectionnement actif', + description: 'Importation pour transformation et réexportation. Suspension des droits sur les intrants.', + }, + { + code: 'IM7', + name: 'Entrepôt douanier', + description: 'Stockage sous douane sans paiement des droits jusqu\'à la mise en consommation.', + }, + { + code: 'EX1', + name: 'Exportation définitive', + description: 'Sortie définitive des marchandises du territoire douanier.', + }, + { + code: 'EX2', + name: 'Exportation temporaire', + description: 'Sortie temporaire avec réimportation prévue en l\'état.', + }, + { + code: 'EX3', + name: 'Perfectionnement passif', + description: 'Exportation pour transformation à l\'étranger et réimportation.', + }, +]; + +const documentsDouane = [ + { + name: 'DAU (Document Administratif Unique)', + description: 'Formulaire standard pour toutes les déclarations douanières dans l\'UE.', + obligatoire: true, + }, + { + name: 'Facture commerciale', + description: 'Document de base indiquant la valeur des marchandises.', + obligatoire: true, + }, + { + name: 'Liste de colisage', + description: 'Détail du contenu de chaque colis (poids, dimensions, contenu).', + obligatoire: true, + }, + { + name: 'Certificat d\'origine', + description: 'Atteste l\'origine des marchandises pour les accords préférentiels.', + obligatoire: false, + }, + { + name: 'Licence d\'importation/exportation', + description: 'Autorisation pour certaines marchandises réglementées.', + obligatoire: false, + }, + { + name: 'Certificat sanitaire/phytosanitaire', + description: 'Pour les produits alimentaires, animaux et végétaux.', + obligatoire: false, + }, +]; + +export default function DouanesPage() { + return ( +
+ {/* Header with back link */} +
+ + + + + Retour au Wiki + +
+ + {/* Title */} +
+
+ 🛃 +

Procédures Douanières

+
+

+ Les formalités douanières sont obligatoires pour toute marchandise traversant une frontière. + Elles permettent le contrôle des échanges, la perception des droits et taxes, et l'application + des réglementations commerciales. +

+
+ + {/* Key Concepts */} + + +

Concepts Clés

+
+
+

Valeur en douane

+

Base de calcul des droits, généralement la valeur CIF (coût + assurance + fret).

+
+
+

Code SH / NC

+

Classification tarifaire harmonisée des marchandises (6 ou 8 chiffres).

+
+
+

Origine

+

Pays de fabrication ou de transformation substantielle de la marchandise.

+
+
+

OEA (Opérateur Économique Agréé)

+

Statut de confiance accordé par les douanes pour des procédures simplifiées.

+
+
+
+
+ + {/* Régimes douaniers */} +
+

📋 Régimes Douaniers

+
+ {regimesDouaniers.map((regime) => ( + + + + + {regime.code} + + {regime.name} + + + +

{regime.description}

+
+
+ ))} +
+
+ + {/* Documents requis */} +
+

📄 Documents Requis

+ + +
+ {documentsDouane.map((doc) => ( +
+ + {doc.obligatoire ? 'Obligatoire' : 'Selon cas'} + +
+

{doc.name}

+

{doc.description}

+
+
+ ))} +
+
+
+
+ + {/* Droits et taxes */} + + +

💰 Droits et Taxes

+
+
+

Droits de douane

+

Pourcentage appliqué sur la valeur en douane selon le code SH.

+

Ex: 0% à 17% selon les produits

+
+
+

TVA import

+

Taxe sur la valeur ajoutée calculée sur (valeur + droits).

+

France: 20%, 10%, 5.5% ou 2.1%

+
+
+

Droits antidumping

+

Droits additionnels pour protéger contre la concurrence déloyale.

+

Variable selon origine et produit

+
+
+
+
+ + {/* Tips */} + + +

⚠️ Points d'Attention

+
    +
  • Toujours vérifier le classement tarifaire avant l'importation
  • +
  • Conserver tous les documents 3 ans minimum (contrôle a posteriori)
  • +
  • Anticiper les contrôles : certificats, licences, normes
  • +
  • Utiliser les accords de libre-échange pour réduire les droits
  • +
  • Attention aux marchandises à double usage (exportation)
  • +
+
+
+
+ ); +} diff --git a/apps/frontend/app/dashboard/wiki/imdg/page.tsx b/apps/frontend/app/dashboard/wiki/imdg/page.tsx new file mode 100644 index 0000000..52d086a --- /dev/null +++ b/apps/frontend/app/dashboard/wiki/imdg/page.tsx @@ -0,0 +1,312 @@ +/** + * Marchandises Dangereuses (IMDG) - Wiki Page + * + * Transport de matières dangereuses par mer + */ + +import Link from 'next/link'; +import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; + +const classesIMDG = [ + { + class: '1', + name: 'Explosifs', + description: 'Matières et objets explosibles', + examples: 'Munitions, feux d\'artifice, détonateurs', + color: 'bg-orange-500', + }, + { + class: '2', + name: 'Gaz', + description: 'Gaz comprimés, liquéfiés ou dissous', + examples: 'Propane, aérosols, oxygène, extincteurs', + color: 'bg-green-500', + subdivisions: ['2.1 Inflammables', '2.2 Non inflammables', '2.3 Toxiques'], + }, + { + class: '3', + name: 'Liquides inflammables', + description: 'Liquides à point d\'éclair bas', + examples: 'Essence, peintures, alcools, solvants', + color: 'bg-red-500', + }, + { + class: '4', + name: 'Solides inflammables', + description: 'Solides facilement inflammables ou auto-réactifs', + examples: 'Allumettes, soufre, métaux en poudre', + color: 'bg-red-400', + subdivisions: ['4.1 Inflammables', '4.2 Auto-inflammables', '4.3 Réagissent avec l\'eau'], + }, + { + class: '5', + name: 'Comburants et peroxydes', + description: 'Matières qui favorisent la combustion', + examples: 'Engrais, peroxydes, agents de blanchiment', + color: 'bg-yellow-500', + subdivisions: ['5.1 Comburants', '5.2 Peroxydes organiques'], + }, + { + class: '6', + name: 'Matières toxiques et infectieuses', + description: 'Matières nocives pour la santé', + examples: 'Pesticides, échantillons médicaux, cyanures', + color: 'bg-purple-500', + subdivisions: ['6.1 Toxiques', '6.2 Infectieuses'], + }, + { + class: '7', + name: 'Matières radioactives', + description: 'Matières émettant des radiations', + examples: 'Isotopes médicaux, sources industrielles', + color: 'bg-yellow-300', + }, + { + class: '8', + name: 'Matières corrosives', + description: 'Matières attaquant les tissus ou métaux', + examples: 'Acide sulfurique, soude caustique, batteries', + color: 'bg-gray-700', + }, + { + class: '9', + name: 'Matières dangereuses diverses', + description: 'Autres matières présentant un danger', + examples: 'Batteries lithium, amiante, glace carbonique', + color: 'bg-gray-400', + }, +]; + +const documentsRequired = [ + { name: 'DGD (Dangerous Goods Declaration)', description: 'Déclaration obligatoire signée par l\'expéditeur' }, + { name: 'Multimodal Dangerous Goods Form', description: 'Formulaire standard OMI/OIT pour le transport multimodal' }, + { name: 'Fiche de Données de Sécurité (FDS/SDS)', description: 'Document technique détaillant les risques et mesures' }, + { name: 'Certificat d\'empotage', description: 'Attestation de bon chargement du conteneur' }, + { name: 'Approval/Exemption', description: 'Autorisations spécifiques pour certaines matières' }, +]; + +const packagingGroups = [ + { group: 'I', danger: 'Élevé', description: 'Matières très dangereuses' }, + { group: 'II', danger: 'Moyen', description: 'Matières moyennement dangereuses' }, + { group: 'III', danger: 'Faible', description: 'Matières légèrement dangereuses' }, +]; + +export default function IMDGPage() { + return ( +
+ {/* Header with back link */} +
+ + + + + Retour au Wiki + +
+ + {/* Title */} +
+
+ ⚠️ +

Marchandises Dangereuses (Code IMDG)

+
+

+ Le Code IMDG (International Maritime Dangerous Goods) est le référentiel international pour + le transport maritime de marchandises dangereuses. Publié par l'OMI, il est mis à jour tous + les deux ans et est obligatoire depuis janvier 2004. +

+
+ + {/* Key Info */} + + +

⚠️ Responsabilités de l'Expéditeur

+
    +
  • Classer correctement la marchandise selon le Code IMDG
  • +
  • Utiliser des emballages homologués UN
  • +
  • Étiqueter et marquer conformément aux exigences
  • +
  • Remplir la déclaration de marchandises dangereuses (DGD)
  • +
  • S'assurer de la formation du personnel impliqué
  • +
+
+
+ + {/* Classes */} +
+

📋 Les 9 Classes de Danger

+
+ {classesIMDG.map((cls) => ( + +
+ + + + {cls.class} + + {cls.name} + + + +

{cls.description}

+

Ex: {cls.examples}

+ {cls.subdivisions && ( +
+

Subdivisions:

+
    + {cls.subdivisions.map((sub) => ( +
  • • {sub}
  • + ))} +
+
+ )} +
+
+ ))} +
+
+ + {/* UN Number */} + + +

🔢 Numéro ONU (UN Number)

+

+ Chaque matière dangereuse est identifiée par un numéro ONU à 4 chiffres. + Ce numéro permet de retrouver toutes les informations dans le Code IMDG. +

+
+
+

UN 1203

+

Essence

+
+
+

UN 2794

+

Batteries acide/plomb

+
+
+

UN 3481

+

Batteries lithium-ion

+
+
+
+
+ + {/* Packaging Groups */} +
+

📦 Groupes d'Emballage

+ + +
+ {packagingGroups.map((pg) => ( +
+ + {pg.group} + +
+

Groupe {pg.group} - Danger {pg.danger}

+

{pg.description}

+
+
+ ))} +
+
+
+
+ + {/* Documents */} +
+

📄 Documents Requis

+ + +
+ {documentsRequired.map((doc) => ( +
+ +
+

{doc.name}

+

{doc.description}

+
+
+ ))} +
+
+
+
+ + {/* Labeling */} + + +

🏷️ Marquage et Étiquetage

+
+
+

Colis

+
    +
  • • Étiquette(s) de danger (losanges)
  • +
  • • Numéro ONU précédé de "UN"
  • +
  • • Nom technique de la matière
  • +
  • • Marque d'homologation UN de l'emballage
  • +
+
+
+

Conteneur

+
    +
  • • Plaques-étiquettes (4 faces)
  • +
  • • Numéro ONU en grands caractères
  • +
  • • Certificat d'empotage affiché
  • +
  • • Marine Pollutant si applicable
  • +
+
+
+
+
+ + {/* Segregation */} + + +

🔀 Ségrégation

+

+ Certaines classes de marchandises dangereuses ne peuvent pas être chargées ensemble. + Le Code IMDG définit des règles strictes de ségrégation : +

+
+
+

1

+

Away from

+
+
+

2

+

Separated from

+
+
+

3

+

Separated by compartment

+
+
+

4

+

Separated longitudinally

+
+
+
+
+ + {/* Tips */} + + +

💡 Conseils Pratiques

+
    +
  • Vérifier l'acceptation par la compagnie maritime (certaines refusent certaines classes)
  • +
  • Anticiper les surcharges DG (dangerous goods) qui peuvent être significatives
  • +
  • Former le personnel aux procédures d'urgence
  • +
  • Utiliser un transitaire spécialisé en marchandises dangereuses
  • +
  • Consulter les réglementations locales (certains ports ont des restrictions)
  • +
  • Batteries lithium : attention aux réglementations très strictes (UN 3480, 3481)
  • +
+
+
+
+ ); +} diff --git a/apps/frontend/app/dashboard/wiki/incoterms/page.tsx b/apps/frontend/app/dashboard/wiki/incoterms/page.tsx new file mode 100644 index 0000000..1e9da2b --- /dev/null +++ b/apps/frontend/app/dashboard/wiki/incoterms/page.tsx @@ -0,0 +1,224 @@ +/** + * Incoterms 2020 - Wiki Page + * + * Detailed information about international commercial terms + */ + +import Link from 'next/link'; +import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; + +const incoterms = [ + { + code: 'EXW', + name: 'Ex Works', + description: 'Le vendeur met la marchandise à disposition dans ses locaux. L\'acheteur assume tous les risques et coûts.', + risk: 'Acheteur', + transport: 'Tous modes', + category: 'Départ', + }, + { + code: 'FCA', + name: 'Free Carrier', + description: 'Le vendeur livre la marchandise au transporteur désigné par l\'acheteur.', + risk: 'Transfert à la livraison au transporteur', + transport: 'Tous modes', + category: 'Départ', + }, + { + code: 'CPT', + name: 'Carriage Paid To', + description: 'Le vendeur paie le transport jusqu\'à destination. Le risque est transféré à la remise au transporteur.', + risk: 'Transfert à la livraison au transporteur', + transport: 'Tous modes', + category: 'Arrivée', + }, + { + code: 'CIP', + name: 'Carriage and Insurance Paid To', + description: 'Comme CPT mais le vendeur doit aussi assurer la marchandise.', + risk: 'Transfert à la livraison au transporteur', + transport: 'Tous modes', + category: 'Arrivée', + }, + { + code: 'DAP', + name: 'Delivered at Place', + description: 'Le vendeur livre la marchandise prête à être déchargée au lieu convenu.', + risk: 'Vendeur jusqu\'à destination', + transport: 'Tous modes', + category: 'Arrivée', + }, + { + code: 'DPU', + name: 'Delivered at Place Unloaded', + description: 'Le vendeur livre et décharge la marchandise au lieu de destination.', + risk: 'Vendeur jusqu\'au déchargement', + transport: 'Tous modes', + category: 'Arrivée', + }, + { + code: 'DDP', + name: 'Delivered Duty Paid', + description: 'Le vendeur assume tous les risques et coûts, y compris les droits de douane.', + risk: 'Vendeur (maximum)', + transport: 'Tous modes', + category: 'Arrivée', + }, + { + code: 'FAS', + name: 'Free Alongside Ship', + description: 'Le vendeur livre la marchandise le long du navire au port d\'embarquement.', + risk: 'Transfert le long du navire', + transport: 'Maritime uniquement', + category: 'Maritime', + }, + { + code: 'FOB', + name: 'Free On Board', + description: 'Le vendeur livre la marchandise à bord du navire. Très utilisé en maritime.', + risk: 'Transfert à bord du navire', + transport: 'Maritime uniquement', + category: 'Maritime', + }, + { + code: 'CFR', + name: 'Cost and Freight', + description: 'Le vendeur paie le fret jusqu\'au port de destination. Le risque passe à bord.', + risk: 'Transfert à bord du navire', + transport: 'Maritime uniquement', + category: 'Maritime', + }, + { + code: 'CIF', + name: 'Cost, Insurance and Freight', + description: 'Comme CFR mais le vendeur doit aussi assurer la marchandise.', + risk: 'Transfert à bord du navire', + transport: 'Maritime uniquement', + category: 'Maritime', + }, +]; + +export default function IncotermsPage() { + const categories = ['Départ', 'Arrivée', 'Maritime']; + + return ( +
+ {/* Header with back link */} +
+ + + + + Retour au Wiki + +
+ + {/* Title */} +
+
+ 📜 +

Incoterms 2020

+
+

+ Les Incoterms (International Commercial Terms) sont des règles publiées par la Chambre de Commerce + Internationale (ICC) qui définissent les responsabilités des vendeurs et acheteurs dans les transactions + internationales. La version 2020 est entrée en vigueur le 1er janvier 2020. +

+
+ + {/* Key Points */} + + +

Points Clés

+
    +
  • 11 Incoterms au total : 7 multimodaux et 4 maritimes
  • +
  • Définissent le transfert des risques et des coûts
  • +
  • Ne définissent PAS le transfert de propriété
  • +
  • Doivent être suivis de la version (ex: FOB Incoterms 2020)
  • +
+
+
+ + {/* Incoterms by category */} + {categories.map((category) => ( +
+

+ {category === 'Maritime' ? '🚢 Incoterms Maritimes' : + category === 'Départ' ? '📤 Incoterms de Départ' : '📥 Incoterms d\'Arrivée'} +

+
+ {incoterms + .filter((term) => term.category === category) + .map((term) => ( + + + + + {term.code} + + {term.name} + + + +

{term.description}

+
+
+ Risque: + {term.risk} +
+
+ Transport: + {term.transport} +
+
+
+
+ ))} +
+
+ ))} + + {/* Visual diagram placeholder */} + + +

Transfert des Risques - Schéma

+
+
+ Vendeur + Acheteur +
+
+
+ EXW + FCA + FOB + CFR/CIF + DAP + DDP +
+
+

+ Plus on va vers la droite, plus le vendeur assume de responsabilités +

+
+
+
+ + {/* Tips */} + + +

Conseils Pratiques

+
    +
  • Utilisez FOB ou CIF pour le maritime, FCA ou CIP pour le multimodal
  • +
  • Précisez toujours le lieu exact (ex: FOB Shanghai Port)
  • +
  • Vérifiez la cohérence entre l'Incoterm et la lettre de crédit
  • +
  • EXW et DDP sont les termes extrêmes - à utiliser avec précaution
  • +
+
+
+
+ ); +} diff --git a/apps/frontend/app/dashboard/wiki/lcl-vs-fcl/page.tsx b/apps/frontend/app/dashboard/wiki/lcl-vs-fcl/page.tsx new file mode 100644 index 0000000..eb89732 --- /dev/null +++ b/apps/frontend/app/dashboard/wiki/lcl-vs-fcl/page.tsx @@ -0,0 +1,283 @@ +/** + * LCL vs FCL - Wiki Page + * + * Comparison between Less than Container Load and Full Container Load + */ + +import Link from 'next/link'; +import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; + +export default function LclVsFclPage() { + return ( +
+ {/* Header with back link */} +
+ + + + + Retour au Wiki + +
+ + {/* Title */} +
+
+ ⚖️ +

LCL vs FCL

+
+

+ Choisir entre LCL (Less than Container Load) et FCL (Full Container Load) est une décision + cruciale qui impacte les coûts, les délais et la sécurité de vos marchandises. +

+
+ + {/* Definitions */} +
+ + + + 📦 + LCL - Groupage + + + +

+ Less than Container Load - Vos marchandises partagent un conteneur + avec d'autres expéditeurs. Vous payez uniquement pour l'espace utilisé. +

+
+

Caractéristiques:

+
    +
  • • Volume: généralement < 15 m³
  • +
  • • Consolidation en entrepôt (CFS)
  • +
  • • Facturation au m³ ou tonne
  • +
  • • Délai transit plus long (+3-7 jours)
  • +
+
+
+
+ + + + + 🚛 + FCL - Complet + + + +

+ Full Container Load - Vous réservez un conteneur entier, même s'il + n'est pas plein. Vos marchandises ne sont pas mélangées. +

+
+

Caractéristiques:

+
    +
  • • Volume: 20', 40' ou 40'HC
  • +
  • • Chargement direct porte-à-porte
  • +
  • • Facturation par conteneur
  • +
  • • Transit direct (plus rapide)
  • +
+
+
+
+
+ + {/* Comparison Table */} +

Comparaison Détaillée

+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CritèreLCLFCL
CoûtPayez au m³/tonnePrix fixe par conteneur
Seuil de rentabilité< 10-15 m³> 15 m³
Transit time+3-7 jours (consolidation)Direct, plus rapide
ManutentionMultiple (risque de dommage)Minimale
SécuritéMarchandises mélangéesConteneur scellé
FlexibilitéHaute (petits volumes)Moyenne
DocumentationPlus complexeSimplifiée
+
+
+
+ + {/* Cost Calculation */} + + + Calcul du Seuil de Rentabilité + + +

+ Le point où FCL devient plus économique que LCL dépend de la route et des tarifs. + Voici un exemple de calcul: +

+
+
+
+

LCL

+

Tarif: 80 €/m³

+

Pour 15 m³: 15 × 80 = 1,200 €

+
+
+

FCL 20'

+

Tarif conteneur: 1,100 €

+

Capacité: ~28 m³ pour 1,100 €

+
+
+

+ Conclusion: Dans cet exemple, le FCL devient rentable à partir de ~14 m³. + Demandez toujours des cotations LCL et FCL pour comparer. +

+
+
+
+ + {/* When to Choose */} +
+ + + Choisissez LCL si: + + +
    +
  • + + Volume inférieur à 10-15 m³ +
  • +
  • + + Expéditions régulières de petites quantités +
  • +
  • + + Test de nouveaux marchés +
  • +
  • + + Marchandises non fragiles +
  • +
  • + + Flexibilité sur les délais +
  • +
+
+
+ + + + Choisissez FCL si: + + +
    +
  • + + Volume supérieur à 15 m³ +
  • +
  • + + Marchandises de valeur ou fragiles +
  • +
  • + + Délais de livraison critiques +
  • +
  • + + Sécurité renforcée requise +
  • +
  • + + Marchandises incompatibles avec d'autres +
  • +
+
+
+
+ + {/* LCL Process */} + + + Processus LCL (Groupage) + + +
+ {[ + { step: '1', title: 'Collecte', desc: 'Enlèvement chez l\'expéditeur' }, + { step: '2', title: 'CFS Origine', desc: 'Consolidation en entrepôt' }, + { step: '3', title: 'Transport', desc: 'Acheminement maritime' }, + { step: '4', title: 'CFS Destination', desc: 'Dégroupage' }, + { step: '5', title: 'Livraison', desc: 'Livraison finale' }, + ].map((item, index) => ( +
+
+
+ {item.step} +
+

{item.title}

+

{item.desc}

+
+ {index < 4 && ( +
+ )} +
+ ))} +
+
+
+ + {/* Tips */} + + +

Conseils Pratiques

+
    +
  • Demandez toujours des cotations LCL ET FCL pour comparer
  • +
  • En LCL, emballez solidement car vos marchandises seront manipulées plusieurs fois
  • +
  • Vérifiez les frais additionnels (CFS, manutention) qui peuvent surprendre en LCL
  • +
  • Un conteneur 40' n'est pas le double du prix d'un 20' - parfois 20-30% plus cher seulement
  • +
+
+
+
+ ); +} diff --git a/apps/frontend/app/dashboard/wiki/lettre-credit/page.tsx b/apps/frontend/app/dashboard/wiki/lettre-credit/page.tsx new file mode 100644 index 0000000..6033c08 --- /dev/null +++ b/apps/frontend/app/dashboard/wiki/lettre-credit/page.tsx @@ -0,0 +1,298 @@ +/** + * Lettre de Crédit (L/C) - Wiki Page + * + * Instrument de paiement international sécurisé + */ + +import Link from 'next/link'; +import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; + +const typesLC = [ + { + type: 'Irrévocable', + description: 'Ne peut être modifiée ou annulée sans l\'accord de toutes les parties', + usage: 'Standard depuis UCP 600', + recommended: true, + }, + { + type: 'Confirmée', + description: 'Une banque (généralement dans le pays du vendeur) ajoute sa garantie', + usage: 'Recommandé pour les pays à risque', + recommended: true, + }, + { + type: 'Transférable', + description: 'Peut être transférée à un second bénéficiaire (sous-traitant)', + usage: 'Négoce, intermédiaires', + recommended: false, + }, + { + type: 'Revolving', + description: 'Se reconstitue automatiquement après chaque utilisation', + usage: 'Commandes répétitives', + recommended: false, + }, + { + type: 'Stand-by', + description: 'Garantie de paiement en cas de défaillance (rarement utilisée)', + usage: 'Garantie bancaire', + recommended: false, + }, + { + type: 'Red/Green Clause', + description: 'Permet un paiement anticipé avant expédition', + usage: 'Préfinancement du vendeur', + recommended: false, + }, +]; + +const parties = [ + { role: 'Donneur d\'ordre (Applicant)', description: 'L\'acheteur/importateur qui demande l\'ouverture de la L/C' }, + { role: 'Bénéficiaire', description: 'Le vendeur/exportateur qui recevra le paiement' }, + { role: 'Banque émettrice', description: 'Banque de l\'acheteur qui émet la L/C' }, + { role: 'Banque notificatrice', description: 'Banque du vendeur qui notifie la L/C (sans engagement)' }, + { role: 'Banque confirmatrice', description: 'Banque qui ajoute sa propre garantie (optionnel)' }, +]; + +const documentsTypiques = [ + { document: 'Facture commerciale', obligatoire: true }, + { document: 'Bill of Lading (connaissement)', obligatoire: true }, + { document: 'Liste de colisage', obligatoire: true }, + { document: 'Certificat d\'origine', obligatoire: false }, + { document: 'Certificat d\'assurance', obligatoire: false }, + { document: 'Certificat d\'inspection', obligatoire: false }, + { document: 'Certificat phytosanitaire', obligatoire: false }, +]; + +const erreursFrequentes = [ + 'Nom du bénéficiaire mal orthographié', + 'Dates d\'expédition ou d\'expiration dépassées', + 'Description des marchandises ne correspondant pas exactement', + 'Documents présentés en retard (au-delà du délai de présentation)', + 'Incoterm incohérent avec les documents', + 'Montant des documents différent de la L/C', + 'Connaissement avec réserves (claused B/L)', +]; + +export default function LettreCreditPage() { + return ( +
+ {/* Header with back link */} +
+ + + + + Retour au Wiki + +
+ + {/* Title */} +
+
+ 💳 +

Lettre de Crédit (L/C)

+
+

+ La lettre de crédit documentaire (L/C ou Documentary Credit) est un engagement de paiement + émis par une banque pour le compte de l'acheteur, garantissant au vendeur d'être payé + contre présentation de documents conformes. Elle est régie par les Règles UCP 600 de la CCI. +

+
+ + {/* How it works */} + + +

🔄 Fonctionnement Simplifié

+
+ {[ + { step: '1', title: 'Demande', desc: 'Acheteur demande l\'ouverture à sa banque' }, + { step: '2', title: 'Émission', desc: 'Banque émettrice envoie la L/C' }, + { step: '3', title: 'Notification', desc: 'Banque du vendeur notifie la L/C' }, + { step: '4', title: 'Expédition', desc: 'Vendeur expédie et présente les documents' }, + { step: '5', title: 'Paiement', desc: 'Banque paie après vérification' }, + ].map((item) => ( +
+
+ {item.step} +
+

{item.title}

+

{item.desc}

+
+ ))} +
+
+
+ + {/* Parties */} +
+

👥 Parties Impliquées

+ + +
+ {parties.map((p) => ( +
+ +
+

{p.role}

+

{p.description}

+
+
+ ))} +
+
+
+
+ + {/* Types */} +
+

📋 Types de Lettres de Crédit

+
+ {typesLC.map((lc) => ( + + + + {lc.type} + {lc.recommended && ( + Recommandé + )} + + + +

{lc.description}

+

Usage : {lc.usage}

+
+
+ ))} +
+
+ + {/* Documents */} +
+

📄 Documents Typiquement Requis

+ + +
+ {documentsTypiques.map((doc) => ( +
+ + {doc.obligatoire ? 'Toujours' : 'Selon L/C'} + + {doc.document} +
+ ))} +
+
+
+
+ + {/* Key Dates */} + + +

📅 Dates Clés à Surveiller

+
+
+

Date d'expédition

+

+ Date limite pour expédier les marchandises (Latest Shipment Date) +

+
+
+

Délai de présentation

+

+ Généralement 21 jours après expédition pour présenter les documents +

+
+
+

Date de validité

+

+ Date limite absolue de la L/C (Expiry Date) +

+
+
+
+
+ + {/* Costs */} + + +

💰 Coûts Typiques

+
+
+
+

Côté Acheteur

+
    +
  • • Commission d'ouverture : 0.1% - 0.5%
  • +
  • • Frais de modification : fixes
  • +
  • • Commission d'engagement : si non utilisée
  • +
+
+
+

Côté Vendeur

+
    +
  • • Frais de notification : fixes
  • +
  • • Commission de confirmation : 0.1% - 2%+
  • +
  • • Frais de négociation : variable
  • +
+
+
+

+ Note : Les frais de confirmation peuvent être très élevés pour les pays à risque. +

+
+
+
+ + {/* Common Errors */} + + +

⚠️ Erreurs Fréquentes (Réserves)

+

+ Ces erreurs entraînent des "réserves" de la banque et peuvent bloquer le paiement : +

+
    + {erreursFrequentes.map((err) => ( +
  • {err}
  • + ))} +
+
+
+ + {/* UCP 600 */} + + +

📜 Règles UCP 600

+

+ Les Règles et Usances Uniformes (UCP 600) de la Chambre de Commerce Internationale (CCI) + régissent les lettres de crédit documentaires depuis 2007. Points clés : +

+
    +
  • Délai standard d'examen des documents : 5 jours ouvrés bancaires
  • +
  • La L/C est irrévocable par défaut (même si non mentionné)
  • +
  • Les banques examinent les documents, pas les marchandises
  • +
  • Les documents doivent être "conformes en apparence"
  • +
+
+
+ + {/* Tips */} + + +

💡 Conseils Pratiques

+
    +
  • Vérifier minutieusement les termes de la L/C dès réception
  • +
  • Demander des modifications AVANT expédition si nécessaire
  • +
  • Préparer les documents exactement comme demandé (virgules, orthographe)
  • +
  • Respecter les délais avec marge de sécurité
  • +
  • Travailler avec un transitaire expérimenté en documentaire
  • +
  • Conserver des copies de tous les documents
  • +
  • Envisager une L/C confirmée pour les nouveaux clients ou pays risqués
  • +
+
+
+
+ ); +} diff --git a/apps/frontend/app/dashboard/wiki/page.tsx b/apps/frontend/app/dashboard/wiki/page.tsx new file mode 100644 index 0000000..713cafe --- /dev/null +++ b/apps/frontend/app/dashboard/wiki/page.tsx @@ -0,0 +1,149 @@ +/** + * Wiki Page - Maritime Import/Export Knowledge Base + * + * Main page displaying cards for all wiki topics + */ + +import Link from 'next/link'; +import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'; + +const wikiTopics = [ + { + title: 'Incoterms 2020', + description: 'Les règles internationales pour l\'interprétation des termes commerciaux', + icon: '📜', + href: '/dashboard/wiki/incoterms', + tags: ['FOB', 'CIF', 'EXW', 'DDP'], + }, + { + title: 'Documents de Transport', + description: 'Les documents essentiels pour le transport maritime', + icon: '📋', + href: '/dashboard/wiki/documents-transport', + tags: ['B/L', 'Sea Waybill', 'Manifest'], + }, + { + title: 'Conteneurs et Types de Cargo', + description: 'Guide complet des types de conteneurs maritimes', + icon: '📦', + href: '/dashboard/wiki/conteneurs', + tags: ['20\'', '40\'', 'Reefer', 'Open Top'], + }, + { + title: 'LCL vs FCL', + description: 'Différences entre groupage et conteneur complet', + icon: '⚖️', + href: '/dashboard/wiki/lcl-vs-fcl', + tags: ['Groupage', 'Complet', 'Coûts'], + }, + { + title: 'Procédures Douanières', + description: 'Guide des formalités douanières import/export', + icon: '🛃', + href: '/dashboard/wiki/douanes', + tags: ['Déclaration', 'Tarifs', 'Régimes'], + }, + { + title: 'Assurance Maritime', + description: 'Protection des marchandises en transit', + icon: '🛡️', + href: '/dashboard/wiki/assurance', + tags: ['ICC A', 'ICC B', 'ICC C'], + }, + { + title: 'Calcul du Fret Maritime', + description: 'Comment sont calculés les coûts de transport', + icon: '🧮', + href: '/dashboard/wiki/calcul-fret', + tags: ['CBM', 'THC', 'BAF', 'CAF'], + }, + { + title: 'Ports et Routes Maritimes', + description: 'Les principales routes commerciales mondiales', + icon: '🌍', + href: '/dashboard/wiki/ports-routes', + tags: ['Hub', 'Détroits', 'Canaux'], + }, + { + title: 'VGM (Verified Gross Mass)', + description: 'Obligation de pesée des conteneurs (SOLAS)', + icon: '⚓', + href: '/dashboard/wiki/vgm', + tags: ['SOLAS', 'Pesée', 'Certification'], + }, + { + title: 'Marchandises Dangereuses (IMDG)', + description: 'Transport de matières dangereuses par mer', + icon: '⚠️', + href: '/dashboard/wiki/imdg', + tags: ['Classes', 'Étiquetage', 'Sécurité'], + }, + { + title: 'Lettre de Crédit (L/C)', + description: 'Instrument de paiement international sécurisé', + icon: '💳', + href: '/dashboard/wiki/lettre-credit', + tags: ['Banque', 'Paiement', 'Sécurité'], + }, + { + title: 'Transit Time et Délais', + description: 'Comprendre les délais en transport maritime', + icon: '⏱️', + href: '/dashboard/wiki/transit-time', + tags: ['Cut-off', 'Free time', 'Demurrage'], + }, +]; + +export default function WikiPage() { + return ( +
+ {/* Header */} +
+

Wiki Maritime

+

+ Base de connaissances sur l'import/export maritime. Cliquez sur un sujet pour en savoir plus. +

+
+ + {/* Cards Grid */} +
+ {wikiTopics.map((topic) => ( + + + +
+ {topic.icon} +
+ + {topic.title} + + + {topic.description} + +
+ +
+ {topic.tags.map((tag) => ( + + {tag} + + ))} +
+
+
+ + ))} +
+ + {/* Footer info */} +
+

+ Besoin d'aide ? Ces guides sont régulièrement mis à jour avec les dernières réglementations et bonnes pratiques du secteur maritime. +

+
+
+ ); +} diff --git a/apps/frontend/app/dashboard/wiki/ports-routes/page.tsx b/apps/frontend/app/dashboard/wiki/ports-routes/page.tsx new file mode 100644 index 0000000..e1566e4 --- /dev/null +++ b/apps/frontend/app/dashboard/wiki/ports-routes/page.tsx @@ -0,0 +1,301 @@ +/** + * Ports et Routes Maritimes - Wiki Page + * + * Les principales routes commerciales mondiales + */ + +import Link from 'next/link'; +import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; + +const majorRoutes = [ + { + name: 'Asie - Europe', + description: 'La plus importante route commerciale mondiale', + transitTime: '28-35 jours', + volume: '~24 millions TEU/an', + keyPorts: ['Shanghai', 'Singapour', 'Rotterdam', 'Hambourg', 'Anvers'], + passages: ['Canal de Suez', 'Détroit de Malacca'], + }, + { + name: 'Asie - Amérique du Nord (Pacifique)', + description: 'Deuxième route la plus importante', + transitTime: '12-18 jours', + volume: '~26 millions TEU/an', + keyPorts: ['Shanghai', 'Busan', 'Los Angeles', 'Long Beach', 'Seattle'], + passages: ['Traversée Pacifique directe'], + }, + { + name: 'Europe - Amérique du Nord (Atlantique)', + description: 'Route transatlantique historique', + transitTime: '8-12 jours', + volume: '~8 millions TEU/an', + keyPorts: ['Rotterdam', 'Anvers', 'New York', 'Savannah', 'Charleston'], + passages: ['Traversée Atlantique directe'], + }, + { + name: 'Asie - Méditerranée', + description: 'Variante de la route Asie-Europe via Med', + transitTime: '18-25 jours', + volume: '~6 millions TEU/an', + keyPorts: ['Shanghai', 'Pirée', 'Gênes', 'Barcelone', 'Tanger Med'], + passages: ['Canal de Suez', 'Détroit de Gibraltar'], + }, +]; + +const strategicPassages = [ + { + name: 'Canal de Suez', + location: 'Égypte', + opened: '1869', + length: '193.3 km', + transitTime: '12-16 heures', + traffic: '~20,000 navires/an', + importance: 'Relie la Méditerranée à la Mer Rouge. Raccourcit de 7,000 km la route Europe-Asie.', + }, + { + name: 'Canal de Panama', + location: 'Panama', + opened: '1914', + length: '82 km', + transitTime: '8-10 heures', + traffic: '~14,000 navires/an', + importance: 'Relie l\'Atlantique au Pacifique. Crucial pour le commerce Asie-Côte Est USA.', + }, + { + name: 'Détroit de Malacca', + location: 'Malaisie/Indonésie', + opened: 'Naturel', + length: '800 km', + transitTime: '12 heures', + traffic: '~90,000 navires/an', + importance: 'Point de passage obligé entre Océan Indien et Mer de Chine. 25% du commerce mondial.', + }, + { + name: 'Détroit de Gibraltar', + location: 'Espagne/Maroc', + opened: 'Naturel', + length: '60 km', + transitTime: '2-3 heures', + traffic: '~100,000 navires/an', + importance: 'Entrée en Méditerranée depuis l\'Atlantique.', + }, +]; + +const majorPorts = [ + { name: 'Shanghai', country: 'Chine', volume: '47.0', rank: 1 }, + { name: 'Singapour', country: 'Singapour', volume: '37.2', rank: 2 }, + { name: 'Ningbo-Zhoushan', country: 'Chine', volume: '33.0', rank: 3 }, + { name: 'Shenzhen', country: 'Chine', volume: '30.0', rank: 4 }, + { name: 'Guangzhou', country: 'Chine', volume: '24.2', rank: 5 }, + { name: 'Busan', country: 'Corée du Sud', volume: '22.7', rank: 6 }, + { name: 'Qingdao', country: 'Chine', volume: '22.0', rank: 7 }, + { name: 'Rotterdam', country: 'Pays-Bas', volume: '14.5', rank: 8 }, + { name: 'Dubai/Jebel Ali', country: 'EAU', volume: '14.1', rank: 9 }, + { name: 'Tianjin', country: 'Chine', volume: '14.0', rank: 10 }, +]; + +export default function PortsRoutesPage() { + return ( +
+ {/* Header with back link */} +
+ + + + + Retour au Wiki + +
+ + {/* Title */} +
+
+ 🌍 +

Ports et Routes Maritimes

+
+

+ Le transport maritime assure plus de 80% du commerce mondial en volume. + Comprendre les grandes routes et les ports stratégiques est essentiel pour optimiser sa supply chain. +

+
+ + {/* Key Stats */} + + +

📊 Chiffres Clés du Maritime Mondial

+
+
+

80%

+

du commerce mondial (volume)

+
+
+

~800M

+

TEU transportés/an

+
+
+

~60,000

+

navires marchands

+
+
+

~14B

+

tonnes de marchandises/an

+
+
+
+
+ + {/* Major Routes */} +
+

🛳️ Routes Commerciales Majeures

+
+ {majorRoutes.map((route) => ( + + + {route.name} + + +

{route.description}

+
+
+ Transit time: +

{route.transitTime}

+
+
+ Volume annuel: +

{route.volume}

+
+
+ Ports clés: +

{route.keyPorts.slice(0, 3).join(', ')}

+
+
+ Passages: +

{route.passages.join(', ')}

+
+
+
+
+ ))} +
+
+ + {/* Strategic Passages */} +
+

⚓ Passages Stratégiques

+
+ {strategicPassages.map((passage) => ( + + + + {passage.name} + {passage.location} + + + +

{passage.importance}

+
+
+ Longueur: +

{passage.length}

+
+
+ Transit: +

{passage.transitTime}

+
+
+ Trafic: +

{passage.traffic}

+
+
+ Ouverture: +

{passage.opened}

+
+
+
+
+ ))} +
+
+ + {/* Top Ports */} +
+

🏆 Top 10 Ports Mondiaux (TEU)

+ + +
+ + + + + + + + + + + {majorPorts.map((port) => ( + + + + + + + ))} + +
RangPortPaysVolume (M TEU)
+ + {port.rank} + + {port.name}{port.country}{port.volume}
+
+

Source: World Shipping Council, données approximatives

+
+
+
+ + {/* Hub Ports Info */} + + +

🔄 Ports Hub vs Ports Régionaux

+
+
+

Ports Hub (Transbordement)

+

+ Grands ports où les conteneurs sont transférés entre navires mères et feeders. + Ex: Singapour, Tanger Med, Algésiras. +

+

Avantage: Desserte mondiale, fréquence élevée

+
+
+

Ports Régionaux (Gateway)

+

+ Ports desservant directement un hinterland économique. + Ex: Le Havre, Marseille, Hambourg. +

+

Avantage: Proximité du marché final, moins de manutention

+
+
+
+
+ + {/* Tips */} + + +

💡 Conseils Pratiques

+
    +
  • Privilégiez les routes directes pour réduire les délais et risques
  • +
  • Anticipez les congestions portuaires (Los Angeles, Rotterdam en haute saison)
  • +
  • Surveillez les perturbations géopolitiques (Canal de Suez, Détroit d'Ormuz)
  • +
  • Comparez les transbordements vs les services directs selon vos priorités (coût vs délai)
  • +
  • Vérifiez les connexions ferroviaires/fluviales depuis les ports
  • +
+
+
+
+ ); +} diff --git a/apps/frontend/app/dashboard/wiki/transit-time/page.tsx b/apps/frontend/app/dashboard/wiki/transit-time/page.tsx new file mode 100644 index 0000000..e4798e3 --- /dev/null +++ b/apps/frontend/app/dashboard/wiki/transit-time/page.tsx @@ -0,0 +1,354 @@ +/** + * Transit Time et Délais - Wiki Page + * + * Comprendre les délais en transport maritime + */ + +import Link from 'next/link'; +import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; + +const etapesTimeline = [ + { + etape: 'Booking', + description: 'Réservation de l\'espace sur le navire', + delai: '1-7 jours avant cut-off', + responsable: 'Transitaire/Exportateur', + }, + { + etape: 'Container pickup', + description: 'Enlèvement du conteneur vide au dépôt', + delai: '2-5 jours avant cut-off', + responsable: 'Transporteur terrestre', + }, + { + etape: 'Empotage (Stuffing)', + description: 'Chargement des marchandises dans le conteneur', + delai: '1-3 jours avant cut-off', + responsable: 'Exportateur', + }, + { + etape: 'Documentation cut-off', + description: 'Date limite pour soumettre les documents (B/L, VGM)', + delai: '24-48h avant ETD', + responsable: 'Transitaire', + }, + { + etape: 'Cargo cut-off', + description: 'Date limite de dépôt du conteneur au terminal', + delai: '24-48h avant ETD', + responsable: 'Transporteur terrestre', + }, + { + etape: 'ETD (Estimated Time of Departure)', + description: 'Départ estimé du navire du port d\'origine', + delai: 'Jour J', + responsable: 'Compagnie maritime', + }, + { + etape: 'Transit maritime', + description: 'Traversée maritime (variable selon route)', + delai: '10-45 jours', + responsable: 'Compagnie maritime', + }, + { + etape: 'ETA (Estimated Time of Arrival)', + description: 'Arrivée estimée au port de destination', + delai: 'Jour J + transit', + responsable: 'Compagnie maritime', + }, + { + etape: 'Déchargement', + description: 'Déchargement du navire et mise à quai', + delai: '1-3 jours après ETA', + responsable: 'Terminal portuaire', + }, + { + etape: 'Dédouanement', + description: 'Formalités douanières à destination', + delai: '1-5 jours', + responsable: 'Commissionnaire en douane', + }, + { + etape: 'Livraison', + description: 'Acheminement final au destinataire', + delai: '1-5 jours', + responsable: 'Transporteur terrestre', + }, +]; + +const fraisRetard = [ + { + nom: 'Demurrage', + definition: 'Frais pour le conteneur resté au terminal au-delà du free time', + taux: '50-150 USD/jour/conteneur', + lieu: 'Terminal portuaire', + }, + { + nom: 'Detention', + definition: 'Frais pour le conteneur gardé hors terminal au-delà du free time', + taux: '30-100 USD/jour/conteneur', + lieu: 'Chez l\'importateur', + }, + { + nom: 'Storage', + definition: 'Frais de stockage au terminal (séparés du demurrage)', + taux: 'Variable selon port', + lieu: 'Terminal portuaire', + }, + { + nom: 'Per Diem', + definition: 'Frais journaliers combinés (parfois utilisé pour demurrage+detention)', + taux: '50-200 USD/jour', + lieu: 'Variable', + }, +]; + +const transitTimes = [ + { route: 'Shanghai → Rotterdam', temps: '28-32 jours', via: 'Suez' }, + { route: 'Shanghai → Le Havre', temps: '30-35 jours', via: 'Suez' }, + { route: 'Shanghai → Los Angeles', temps: '12-15 jours', via: 'Direct Pacifique' }, + { route: 'Shanghai → New York', temps: '35-40 jours', via: 'Suez ou Panama' }, + { route: 'Rotterdam → New York', temps: '10-14 jours', via: 'Direct Atlantique' }, + { route: 'Mumbai → Rotterdam', temps: '18-22 jours', via: 'Suez' }, + { route: 'Santos → Rotterdam', temps: '18-22 jours', via: 'Direct Atlantique' }, +]; + +export default function TransitTimePage() { + return ( +
+ {/* Header with back link */} +
+ + + + + Retour au Wiki + +
+ + {/* Title */} +
+
+ ⏱️ +

Transit Time et Délais

+
+

+ La gestion des délais est cruciale en transport maritime. Comprendre les différentes étapes, + les cut-off dates et les frais de retard permet d'optimiser sa supply chain et d'éviter + les coûts supplémentaires. +

+
+ + {/* Key Terms */} + + +

📖 Termes Clés

+
+
+

ETD

+

Estimated Time of Departure - Départ estimé

+
+
+

ETA

+

Estimated Time of Arrival - Arrivée estimée

+
+
+

Cut-off

+

Date/heure limite de dépôt

+
+
+

Free time

+

Jours gratuits avant frais de retard

+
+
+
+
+ + {/* Timeline */} +
+

📅 Timeline d'une Expédition FCL

+
+ {etapesTimeline.map((item, index) => ( + + +
+
+ {index + 1} +
+
+
+

{item.etape}

+ {item.delai} +
+

{item.description}

+

Responsable : {item.responsable}

+
+
+
+
+ ))} +
+
+ + {/* Transit Times */} +
+

🚢 Transit Times Indicatifs

+ + +
+ + + + + + + + + + {transitTimes.map((tt) => ( + + + + + + ))} + +
RouteTransit TimeVia
{tt.route}{tt.temps}{tt.via}
+
+

+ Note : Ces temps sont indicatifs et varient selon les rotations, transbordements et conditions. +

+
+
+
+ + {/* Free Time */} + + +

⏰ Free Time (Jours Gratuits)

+

+ Période pendant laquelle le conteneur peut rester au terminal ou chez l'importateur + sans frais supplémentaires. +

+
+
+

Free time standard

+

7-14 jours

+

Selon compagnie et port

+
+
+

Demurrage start

+

+ Commence après le free time au terminal +

+
+
+

Detention start

+

+ Commence à la sortie du terminal (gate-out) +

+
+
+
+
+ + {/* Late Fees */} +
+

💸 Frais de Retard

+
+ {fraisRetard.map((frais) => ( + + + {frais.nom} + + +

{frais.definition}

+
+ Taux indicatif : + {frais.taux} +
+
+ Lieu : + {frais.lieu} +
+
+
+ ))} +
+
+ + {/* Factors affecting transit */} + + +

⚡ Facteurs Impactant les Délais

+
+
+

Retards potentiels

+
    +
  • • Congestion portuaire (Los Angeles, Rotterdam)
  • +
  • • Conditions météorologiques (typhons, tempêtes)
  • +
  • • Fermeture de canaux (Suez, Panama)
  • +
  • • Inspection douanière (scanner, contrôle)
  • +
  • • Blank sailings (annulation de rotation)
  • +
  • • Grèves (dockers, transporteurs)
  • +
+
+
+

Variations saisonnières

+
    +
  • • Nouvel An Chinois (février) : +2-3 semaines
  • +
  • • Golden Week (octobre) : congestion Asie
  • +
  • • Peak Season (août-octobre) : surcharges, retards
  • +
  • • Fêtes de fin d'année : rush avant Christmas
  • +
+
+
+
+
+ + {/* Roll-over */} + + +

🔄 Roll-over (Report)

+

+ Situation où un conteneur n'est pas chargé sur le navire prévu et est reporté + sur le prochain départ. +

+
+

Causes fréquentes :

+
    +
  • • Navire plein (overbooking)
  • +
  • • Conteneur arrivé après le cargo cut-off
  • +
  • • Documents manquants ou incorrects
  • +
  • • VGM non transmis à temps
  • +
  • • Problème avec la marchandise (DG, inspection)
  • +
+

+ Impact : Généralement +7 jours de délai (service hebdomadaire) +

+
+
+
+ + {/* Tips */} + + +

💡 Conseils pour Optimiser les Délais

+
    +
  • Réserver tôt, surtout en haute saison (2-3 semaines d'avance)
  • +
  • Respecter les cut-off avec une marge de sécurité (24h minimum)
  • +
  • Préparer les documents en parallèle de l'empotage
  • +
  • Négocier du free time supplémentaire pour les volumes importants
  • +
  • Tracker activement les navires (AIS, portails compagnies)
  • +
  • Anticiper le dédouanement (pré-clearing si possible)
  • +
  • Avoir un plan B en cas de roll-over (service alternatif)
  • +
  • Éviter les expéditions critiques pendant les périodes à risque
  • +
+
+
+
+ ); +} diff --git a/apps/frontend/app/dashboard/wiki/vgm/page.tsx b/apps/frontend/app/dashboard/wiki/vgm/page.tsx new file mode 100644 index 0000000..b0c81ca --- /dev/null +++ b/apps/frontend/app/dashboard/wiki/vgm/page.tsx @@ -0,0 +1,268 @@ +/** + * VGM (Verified Gross Mass) - Wiki Page + * + * Obligation de pesée des conteneurs (SOLAS) + */ + +import Link from 'next/link'; +import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; + +const methodesPesee = [ + { + method: 'Méthode 1', + name: 'Pesée du conteneur complet', + description: 'Pesée du conteneur chargé et scellé sur une balance étalonnée.', + process: [ + 'Empotage du conteneur', + 'Scellage du conteneur', + 'Pesée sur pont-bascule certifié', + 'Transmission du VGM', + ], + advantages: ['Plus précis', 'Moins de calculs'], + disadvantages: ['Nécessite un pont-bascule', 'Conteneur déjà scellé'], + }, + { + method: 'Méthode 2', + name: 'Calcul par addition', + description: 'Addition de la tare du conteneur et du poids de tous les éléments chargés.', + process: [ + 'Pesée de chaque colis individuellement', + 'Addition de tous les poids', + 'Ajout des matériaux d\'arrimage', + 'Addition de la tare conteneur', + ], + advantages: ['Pas besoin de pont-bascule', 'Peut être fait progressivement'], + disadvantages: ['Plus complexe', 'Risque d\'erreur cumulative'], + }, +]; + +const sanctions = [ + { region: 'France', sanction: 'Amende jusqu\'à 7,500€ et refus d\'embarquement' }, + { region: 'USA', sanction: 'Refus d\'embarquement, amende par la garde côtière' }, + { region: 'Chine', sanction: 'Refus d\'embarquement, pénalités portuaires' }, + { region: 'Union Européenne', sanction: 'Application variable selon pays membre' }, +]; + +const elementsVGM = [ + { element: 'Tare conteneur', description: 'Poids à vide du conteneur (inscrit sur la porte)', example: '2,200 kg (20\')' }, + { element: 'Marchandises', description: 'Poids brut de toutes les marchandises', example: 'Variable' }, + { element: 'Emballages', description: 'Palettes, cartons, film plastique...', example: '200-500 kg' }, + { element: 'Matériaux d\'arrimage', description: 'Bois de calage, sangles, airbags...', example: '50-200 kg' }, +]; + +export default function VGMPage() { + return ( +
+ {/* Header with back link */} +
+ + + + + Retour au Wiki + +
+ + {/* Title */} +
+
+ +

VGM (Verified Gross Mass)

+
+

+ Depuis le 1er juillet 2016, la Convention SOLAS (Safety of Life at Sea) exige que le poids + vérifié de tout conteneur soit transmis avant embarquement. Cette obligation vise à prévenir + les accidents liés aux conteneurs mal déclarés. +

+
+ + {/* Why VGM */} + + +

Pourquoi le VGM ?

+
+
+

🛡️ Sécurité

+

Les conteneurs mal déclarés causent des accidents graves (chute de conteneurs, navires instables).

+
+
+

⚖️ Stabilité du navire

+

Le capitaine doit connaître le poids exact pour calculer le plan de chargement.

+
+
+

🏗️ Équipements portuaires

+

Les grues et portiques sont dimensionnés pour des charges maximales.

+
+
+

🚛 Transport terrestre

+

Évite les surcharges sur les camions et wagons de pré/post-acheminement.

+
+
+
+
+ + {/* VGM Components */} +
+

📋 Composants du VGM

+ + +
+

+ VGM = Tare + Marchandises + Emballages + Arrimage +

+
+
+ {elementsVGM.map((item) => ( +
+
+

{item.element}

+

{item.description}

+
+ {item.example} +
+ ))} +
+
+
+
+ + {/* Methods */} +
+

🔬 Méthodes de Détermination

+
+ {methodesPesee.map((method) => ( + + + + + {method.method} + + {method.name} + + + +

{method.description}

+
+
+

Processus

+
    + {method.process.map((step, idx) => ( +
  1. {step}
  2. + ))} +
+
+
+

✓ Avantages

+
    + {method.advantages.map((adv) => ( +
  • + + {adv} +
  • + ))} +
+
+
+

✗ Inconvénients

+
    + {method.disadvantages.map((dis) => ( +
  • + + {dis} +
  • + ))} +
+
+
+
+
+ ))} +
+
+ + {/* Responsibility */} + + +

👤 Responsabilités

+
+
+

Expéditeur (Shipper)

+

+ Responsable légal du VGM. Doit obtenir, certifier et transmettre le poids vérifié. +

+
+
+

Transitaire

+

+ Peut transmettre le VGM pour le compte de l'expéditeur. Reste un intermédiaire. +

+
+
+

Compagnie maritime

+

+ Ne peut embarquer un conteneur sans VGM. Peut refuser un VGM manifestement erroné. +

+
+
+
+
+ + {/* Tolerances */} + + +

📏 Tolérances

+
+

+ Les tolérances varient selon les compagnies maritimes et les ports, mais généralement : +

+
+
+ Tolérance typique : +

± 5% du poids déclaré ou ± 500 kg (le plus petit des deux)

+
+
+ Conséquence si dépassement : +

Nouvelle pesée à la charge de l'expéditeur, retard possible

+
+
+
+
+
+ + {/* Sanctions */} +
+

⚠️ Sanctions par Région

+ + +
+ {sanctions.map((s) => ( +
+ {s.region} + {s.sanction} +
+ ))} +
+
+
+
+ + {/* Tips */} + + +

💡 Bonnes Pratiques

+
    +
  • Transmettre le VGM au moins 24-48h avant le cut-off
  • +
  • Utiliser des balances étalonnées et certifiées
  • +
  • Conserver les preuves de pesée pendant 3 ans minimum
  • +
  • Vérifier les exigences spécifiques de chaque compagnie maritime
  • +
  • Former le personnel aux procédures VGM
  • +
  • Ne jamais sous-estimer le poids intentionnellement
  • +
+
+
+
+ ); +} diff --git a/apps/frontend/src/components/organization/LicensesTab.tsx b/apps/frontend/src/components/organization/LicensesTab.tsx new file mode 100644 index 0000000..6f93a71 --- /dev/null +++ b/apps/frontend/src/components/organization/LicensesTab.tsx @@ -0,0 +1,360 @@ +/** + * Licenses Tab Component + * + * Manages user licenses within the organization + */ + +'use client'; + +import { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { getSubscriptionOverview } from '@/lib/api/subscriptions'; + +export default function LicensesTab() { + const [error, setError] = useState(''); + const [success, setSuccess] = useState(''); + + const { data: subscription, isLoading } = useQuery({ + queryKey: ['subscription'], + queryFn: getSubscriptionOverview, + }); + + if (isLoading) { + return ( +
+
+
+
+
+
+ ); + } + + const licenses = subscription?.licenses || []; + const activeLicenses = licenses.filter((l) => l.status === 'ACTIVE'); + const revokedLicenses = licenses.filter((l) => l.status === 'REVOKED'); + + const usagePercentage = subscription + ? subscription.maxLicenses === -1 + ? 0 + : (subscription.usedLicenses / subscription.maxLicenses) * 100 + : 0; + + return ( +
+ {/* Alerts */} + {error && ( +
+ {error} +
+ )} + {success && ( +
+ {success} +
+ )} + + {/* License Summary */} +
+

Résumé des licences

+
+
+

Licences utilisées

+

+ {subscription?.usedLicenses || 0} +

+

Hors ADMIN (illimité)

+
+
+

Licences disponibles

+

+ {subscription?.availableLicenses === -1 + ? 'Illimité' + : subscription?.availableLicenses || 0} +

+
+
+

Licences totales

+

+ {subscription?.maxLicenses === -1 + ? 'Illimité' + : subscription?.maxLicenses || 0} +

+
+
+ + {/* Usage Bar */} + {subscription && subscription.maxLicenses !== -1 && ( +
+
+ Utilisation + + {Math.round(usagePercentage)}% + +
+
+
= 90 + ? 'bg-red-600' + : usagePercentage >= 70 + ? 'bg-yellow-500' + : 'bg-blue-600' + }`} + style={{ width: `${Math.min(usagePercentage, 100)}%` }} + >
+
+
+ )} +
+ + {/* Active Licenses */} +
+
+

+ Licences actives ({activeLicenses.length}) +

+
+ {activeLicenses.length === 0 ? ( +
+ Aucune licence active +
+ ) : ( +
+ + + + + + + + + + + + {activeLicenses.map((license) => { + const isAdmin = license.userRole === 'ADMIN'; + return ( + + + + + + + + ); + })} + +
+ Utilisateur + + Email + + Rôle + + Assignée le + + Licence +
+
+ {license.userName} +
+
+
{license.userEmail}
+
+ + {license.userRole} + + +
+ {new Date(license.assignedAt).toLocaleDateString('fr-FR', { + day: 'numeric', + month: 'long', + year: 'numeric', + })} +
+
+ {isAdmin ? ( + + Illimité + + ) : ( + + Active + + )} +
+
+ )} +
+ + {/* Revoked Licenses (History) */} + {revokedLicenses.length > 0 && ( +
+
+

+ Historique des licences révoquées ({revokedLicenses.length}) +

+
+
+ + + + + + + + + + + + + {revokedLicenses.map((license) => ( + + + + + + + + + ))} + +
+ Utilisateur + + Email + + Rôle + + Assignée le + + Révoquée le + + Statut +
+
+ {license.userName} +
+
+
{license.userEmail}
+
+ + {license.userRole} + + +
+ {new Date(license.assignedAt).toLocaleDateString('fr-FR', { + day: 'numeric', + month: 'long', + year: 'numeric', + })} +
+
+
+ {license.revokedAt + ? new Date(license.revokedAt).toLocaleDateString('fr-FR', { + day: 'numeric', + month: 'long', + year: 'numeric', + }) + : '-'} +
+
+ + Révoquée + +
+
+
+ )} + + {/* Info Box */} +
+
+
+ + + +
+
+

+ Comment fonctionnent les licences ? +

+
+
    +
  • + Chaque utilisateur actif de votre organisation consomme une licence +
  • +
  • + Les administrateurs (ADMIN) ont des licences illimitées et ne sont pas comptés dans le quota +
  • +
  • + Les licences sont automatiquement assignées lors de l'ajout d'un + utilisateur +
  • +
  • + Les licences sont libérées lorsqu'un utilisateur est désactivé ou + supprimé +
  • +
  • + Pour ajouter plus d'utilisateurs, passez à un plan supérieur dans + l'onglet Abonnement +
  • +
+
+
+
+
+
+ ); +} diff --git a/apps/frontend/src/components/organization/SubscriptionTab.tsx b/apps/frontend/src/components/organization/SubscriptionTab.tsx new file mode 100644 index 0000000..30efbff --- /dev/null +++ b/apps/frontend/src/components/organization/SubscriptionTab.tsx @@ -0,0 +1,443 @@ +/** + * Subscription Tab Component + * + * Manages subscription plan, billing, and upgrade flows + */ + +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { useSearchParams, useRouter } from 'next/navigation'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { + getSubscriptionOverview, + getAllPlans, + createCheckoutSession, + createPortalSession, + syncSubscriptionFromStripe, + formatPrice, + getPlanBadgeColor, + getStatusBadgeColor, + type SubscriptionPlan, + type BillingInterval, +} from '@/lib/api/subscriptions'; + +export default function SubscriptionTab() { + const searchParams = useSearchParams(); + const router = useRouter(); + const queryClient = useQueryClient(); + const [billingInterval, setBillingInterval] = useState('monthly'); + const [selectedPlan, setSelectedPlan] = useState(null); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(''); + const [isRefreshing, setIsRefreshing] = useState(false); + + const { data: subscription, isLoading: loadingSubscription, refetch: refetchSubscription } = useQuery({ + queryKey: ['subscription'], + queryFn: getSubscriptionOverview, + // Refetch more frequently when we're waiting for webhook + refetchInterval: isRefreshing ? 2000 : false, + }); + + const { data: plansData, isLoading: loadingPlans } = useQuery({ + queryKey: ['plans'], + queryFn: getAllPlans, + }); + + // Handle success/cancel from Stripe redirect + const handleStripeRedirect = useCallback(async () => { + const isSuccess = searchParams.get('success') === 'true'; + const isCanceled = searchParams.get('canceled') === 'true'; + const sessionId = searchParams.get('session_id') || undefined; + + if (isSuccess) { + setSuccess('Votre abonnement a été mis à jour avec succès !'); + setIsRefreshing(true); + + try { + // Sync from Stripe using the session ID (works even without webhooks) + // The session ID allows us to retrieve the subscription from the checkout session + console.log('Syncing subscription with sessionId:', sessionId); + await syncSubscriptionFromStripe(sessionId); + + // Then invalidate and refetch to get fresh data + await queryClient.invalidateQueries({ queryKey: ['subscription'] }); + await queryClient.invalidateQueries({ queryKey: ['canInvite'] }); + await refetchSubscription(); + + // Wait a bit and refetch again to ensure data is up to date + await new Promise(resolve => setTimeout(resolve, 1500)); + await syncSubscriptionFromStripe(sessionId); + await queryClient.invalidateQueries({ queryKey: ['subscription'] }); + await refetchSubscription(); + } catch (err) { + console.error('Error syncing subscription:', err); + // Fallback: just refetch + await refetchSubscription(); + } + + setIsRefreshing(false); + + // Clear the URL params after processing + router.replace('/dashboard/settings/organization', { scroll: false }); + + setTimeout(() => setSuccess(''), 5000); + } else if (isCanceled) { + setError('Le paiement a été annulé. Votre abonnement n\'a pas été modifié.'); + router.replace('/dashboard/settings/organization', { scroll: false }); + setTimeout(() => setError(''), 5000); + } + }, [searchParams, queryClient, refetchSubscription, router]); + + useEffect(() => { + handleStripeRedirect(); + }, [handleStripeRedirect]); + + const checkoutMutation = useMutation({ + mutationFn: (plan: SubscriptionPlan) => + createCheckoutSession({ plan, billingInterval }), + onSuccess: (data) => { + window.location.href = data.sessionUrl; + }, + onError: (err: Error) => { + setError(err.message || 'Erreur lors de la création de la session de paiement'); + setTimeout(() => setError(''), 5000); + }, + }); + + const portalMutation = useMutation({ + mutationFn: () => createPortalSession(), + onSuccess: (data) => { + window.location.href = data.sessionUrl; + }, + onError: (err: Error) => { + setError(err.message || 'Erreur lors de l\'ouverture du portail de facturation'); + setTimeout(() => setError(''), 5000); + }, + }); + + const handleUpgrade = (plan: SubscriptionPlan) => { + if (plan === 'FREE') return; + setSelectedPlan(plan); + checkoutMutation.mutate(plan); + }; + + const handleManageBilling = () => { + portalMutation.mutate(); + }; + + const handleRefresh = async () => { + setIsRefreshing(true); + try { + // Sync from Stripe first + await syncSubscriptionFromStripe(); + // Then invalidate and refetch + await queryClient.invalidateQueries({ queryKey: ['subscription'] }); + await queryClient.invalidateQueries({ queryKey: ['canInvite'] }); + await refetchSubscription(); + } catch (err) { + console.error('Error syncing subscription:', err); + // Fallback: just refetch + await refetchSubscription(); + } + setIsRefreshing(false); + }; + + const isCurrentPlan = (plan: SubscriptionPlan): boolean => { + return subscription?.plan === plan; + }; + + const canUpgrade = (plan: SubscriptionPlan): boolean => { + if (!subscription) return false; + const planOrder: SubscriptionPlan[] = ['FREE', 'STARTER', 'PRO', 'ENTERPRISE']; + return planOrder.indexOf(plan) > planOrder.indexOf(subscription.plan); + }; + + const isLoading = loadingSubscription || loadingPlans; + + if (isLoading) { + return ( +
+
+
+
+
+
+
+ ); + } + + const usagePercentage = subscription + ? subscription.maxLicenses === -1 + ? 0 + : (subscription.usedLicenses / subscription.maxLicenses) * 100 + : 0; + + return ( +
+ {/* Alerts */} + {error && ( +
+ {error} +
+ )} + {success && ( +
+ {success} + {isRefreshing && ( + + + + + + Mise à jour... + + )} +
+ )} + + {/* Current Plan */} + {subscription && ( +
+
+

Plan actuel

+ +
+
+
+ + {subscription.planDetails.name} + + + {subscription.status === 'ACTIVE' ? 'Actif' : subscription.status} + + {subscription.cancelAtPeriodEnd && ( + + Annulation prévue + + )} +
+ {subscription.plan !== 'FREE' && ( + + )} +
+ + {/* License Usage */} +
+
+ Utilisation des licences + + {subscription.usedLicenses} /{' '} + {subscription.maxLicenses === -1 ? 'Illimité' : subscription.maxLicenses} + +
+
+
= 90 + ? 'bg-red-600' + : usagePercentage >= 70 + ? 'bg-yellow-500' + : 'bg-blue-600' + }`} + style={{ + width: + subscription.maxLicenses === -1 + ? '10%' + : `${Math.min(usagePercentage, 100)}%`, + }} + >
+
+ {subscription.availableLicenses !== -1 && subscription.availableLicenses <= 2 && ( +

+ {subscription.availableLicenses === 0 + ? 'Aucune licence disponible. Passez à un plan supérieur pour ajouter des utilisateurs.' + : `Plus que ${subscription.availableLicenses} licence${subscription.availableLicenses === 1 ? '' : 's'} disponible${subscription.availableLicenses === 1 ? '' : 's'}.`} +

+ )} +

+ Les administrateurs (ADMIN) ont des licences illimitées et ne sont pas comptés. +

+
+ + {/* Billing Period */} + {subscription.currentPeriodEnd && ( +
+ Période actuelle : jusqu'au{' '} + {new Date(subscription.currentPeriodEnd).toLocaleDateString('fr-FR', { + day: 'numeric', + month: 'long', + year: 'numeric', + })} +
+ )} +
+ )} + + {/* Plans Grid */} +
+
+

Plans disponibles

+
+ + +
+
+ +
+ {plansData?.plans.map((plan) => ( +
+
+

{plan.name}

+
+ + {plan.plan === 'ENTERPRISE' + ? 'Sur devis' + : formatPrice( + billingInterval === 'yearly' + ? plan.yearlyPriceEur + : plan.monthlyPriceEur, + )} + + {plan.plan !== 'ENTERPRISE' && plan.plan !== 'FREE' && ( + + /{billingInterval === 'yearly' ? 'an' : 'mois'} + + )} +
+

+ {plan.maxLicenses === -1 + ? 'Utilisateurs illimités' + : `Jusqu'à ${plan.maxLicenses} utilisateurs`} +

+
+ +
    + {plan.features.slice(0, 4).map((feature, index) => ( +
  • + + + + {feature} +
  • + ))} +
+ +
+ {isCurrentPlan(plan.plan) ? ( + + ) : plan.plan === 'ENTERPRISE' ? ( + + Nous contacter + + ) : canUpgrade(plan.plan) ? ( + + ) : ( + + )} +
+
+ ))} +
+
+ + {/* Info about webhooks in development */} + {process.env.NODE_ENV === 'development' && ( +
+
+
+ + + +
+
+

Mode développement

+
+

+ Pour que les webhooks Stripe fonctionnent en local, exécutez :{' '} + stripe listen --forward-to localhost:4000/api/v1/subscriptions/webhook +

+
+
+
+
+ )} +
+ ); +} diff --git a/apps/frontend/src/lib/api/index.ts b/apps/frontend/src/lib/api/index.ts index 88905ac..7a850c8 100644 --- a/apps/frontend/src/lib/api/index.ts +++ b/apps/frontend/src/lib/api/index.ts @@ -136,6 +136,26 @@ export { type DashboardAlert, } from './dashboard'; +// Subscriptions (5 endpoints) +export { + getSubscriptionOverview, + getAllPlans, + canInviteUser, + createCheckoutSession, + createPortalSession, + formatPrice, + getPlanBadgeColor, + getStatusBadgeColor, + type SubscriptionPlan, + type SubscriptionStatus, + type BillingInterval, + type PlanDetails, + type LicenseResponse, + type SubscriptionOverviewResponse, + type CanInviteResponse, + type AllPlansResponse, +} from './subscriptions'; + // Re-export as API objects for backward compatibility import * as bookingsModule from './bookings'; import * as ratesModule from './rates'; @@ -146,6 +166,7 @@ import * as notificationsModule from './notifications'; import * as auditModule from './audit'; import * as webhooksModule from './webhooks'; import * as gdprModule from './gdpr'; +import * as subscriptionsModule from './subscriptions'; export const bookingsApi = bookingsModule; export const ratesApi = ratesModule; @@ -156,3 +177,4 @@ export const notificationsApi = notificationsModule; export const auditApi = auditModule; export const webhooksApi = webhooksModule; export const gdprApi = gdprModule; +export const subscriptionsApi = subscriptionsModule; diff --git a/apps/frontend/src/lib/api/subscriptions.ts b/apps/frontend/src/lib/api/subscriptions.ts new file mode 100644 index 0000000..2e6b96b --- /dev/null +++ b/apps/frontend/src/lib/api/subscriptions.ts @@ -0,0 +1,226 @@ +/** + * Subscriptions API Client + * + * API functions for subscription and license management + */ + +import { get, post } from './client'; + +/** + * Subscription plan types + */ +export type SubscriptionPlan = 'FREE' | 'STARTER' | 'PRO' | 'ENTERPRISE'; + +/** + * Subscription status types + */ +export type SubscriptionStatus = + | 'ACTIVE' + | 'PAST_DUE' + | 'CANCELED' + | 'INCOMPLETE' + | 'INCOMPLETE_EXPIRED' + | 'TRIALING' + | 'UNPAID' + | 'PAUSED'; + +/** + * Billing interval types + */ +export type BillingInterval = 'monthly' | 'yearly'; + +/** + * Plan details + */ +export interface PlanDetails { + plan: SubscriptionPlan; + name: string; + maxLicenses: number; + monthlyPriceEur: number; + yearlyPriceEur: number; + features: string[]; +} + +/** + * License response + */ +export interface LicenseResponse { + id: string; + userId: string; + userEmail: string; + userName: string; + userRole: string; + status: 'ACTIVE' | 'REVOKED'; + assignedAt: string; + revokedAt?: string; +} + +/** + * Subscription overview response + */ +export interface SubscriptionOverviewResponse { + id: string; + organizationId: string; + plan: SubscriptionPlan; + planDetails: PlanDetails; + status: SubscriptionStatus; + usedLicenses: number; + maxLicenses: number; + availableLicenses: number; + cancelAtPeriodEnd: boolean; + currentPeriodStart?: string; + currentPeriodEnd?: string; + createdAt: string; + updatedAt: string; + licenses: LicenseResponse[]; +} + +/** + * Can invite response + */ +export interface CanInviteResponse { + canInvite: boolean; + availableLicenses: number; + usedLicenses: number; + maxLicenses: number; + message?: string; +} + +/** + * All plans response + */ +export interface AllPlansResponse { + plans: PlanDetails[]; +} + +/** + * Checkout session request + */ +export interface CreateCheckoutSessionRequest { + plan: SubscriptionPlan; + billingInterval: BillingInterval; + successUrl?: string; + cancelUrl?: string; +} + +/** + * Checkout session response + */ +export interface CheckoutSessionResponse { + sessionId: string; + sessionUrl: string; +} + +/** + * Portal session request + */ +export interface CreatePortalSessionRequest { + returnUrl?: string; +} + +/** + * Portal session response + */ +export interface PortalSessionResponse { + sessionUrl: string; +} + +/** + * Get subscription overview for current organization + */ +export async function getSubscriptionOverview(): Promise { + return get('/api/v1/subscriptions'); +} + +/** + * Get all available plans + */ +export async function getAllPlans(): Promise { + return get('/api/v1/subscriptions/plans'); +} + +/** + * Check if organization can invite more users + */ +export async function canInviteUser(): Promise { + return get('/api/v1/subscriptions/can-invite'); +} + +/** + * Create a Stripe Checkout session for subscription upgrade + */ +export async function createCheckoutSession( + data: CreateCheckoutSessionRequest, +): Promise { + return post('/api/v1/subscriptions/checkout', data); +} + +/** + * Create a Stripe Customer Portal session + */ +export async function createPortalSession( + data?: CreatePortalSessionRequest, +): Promise { + return post('/api/v1/subscriptions/portal', data || {}); +} + +/** + * Sync subscription from Stripe + * Useful when webhooks are not available (e.g., local development) + * @param sessionId - Optional Stripe checkout session ID (pass after checkout completes) + */ +export async function syncSubscriptionFromStripe(sessionId?: string): Promise { + return post('/api/v1/subscriptions/sync', { sessionId }); +} + +/** + * Format price for display + */ +export function formatPrice(amount: number, currency = 'EUR'): string { + return new Intl.NumberFormat('fr-FR', { + style: 'currency', + currency, + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(amount); +} + +/** + * Get plan badge color class + */ +export function getPlanBadgeColor(plan: SubscriptionPlan): string { + switch (plan) { + case 'FREE': + return 'bg-gray-100 text-gray-800'; + case 'STARTER': + return 'bg-blue-100 text-blue-800'; + case 'PRO': + return 'bg-purple-100 text-purple-800'; + case 'ENTERPRISE': + return 'bg-amber-100 text-amber-800'; + default: + return 'bg-gray-100 text-gray-800'; + } +} + +/** + * Get status badge color class + */ +export function getStatusBadgeColor(status: SubscriptionStatus): string { + switch (status) { + case 'ACTIVE': + case 'TRIALING': + return 'bg-green-100 text-green-800'; + case 'PAST_DUE': + return 'bg-yellow-100 text-yellow-800'; + case 'CANCELED': + case 'INCOMPLETE_EXPIRED': + case 'UNPAID': + return 'bg-red-100 text-red-800'; + case 'INCOMPLETE': + case 'PAUSED': + return 'bg-gray-100 text-gray-800'; + default: + return 'bg-gray-100 text-gray-800'; + } +} diff --git a/docs/STRIPE_SETUP.md b/docs/STRIPE_SETUP.md new file mode 100644 index 0000000..fcfc599 --- /dev/null +++ b/docs/STRIPE_SETUP.md @@ -0,0 +1,219 @@ +# Configuration Stripe pour Xpeditis + +Ce guide explique comment configurer Stripe pour le système de licences et d'abonnements. + +## 1. Prérequis + +- Compte Stripe (https://dashboard.stripe.com) +- Accès aux clés API Stripe + +## 2. Configuration du Dashboard Stripe + +### 2.1 Créer les Produits + +Dans le Dashboard Stripe, allez dans **Products** et créez les produits suivants : + +| Produit | Description | +|---------|-------------| +| **Xpeditis Starter** | Plan Starter - Jusqu'à 5 utilisateurs | +| **Xpeditis Pro** | Plan Pro - Jusqu'à 20 utilisateurs | +| **Xpeditis Enterprise** | Plan Enterprise - Utilisateurs illimités | + +### 2.2 Créer les Prix + +Pour chaque produit, créez 2 prix (mensuel et annuel) : + +#### Starter +| Type | Prix | Récurrence | +|------|------|------------| +| Mensuel | 49 EUR | /mois | +| Annuel | 470 EUR | /an (~20% de réduction) | + +#### Pro +| Type | Prix | Récurrence | +|------|------|------------| +| Mensuel | 149 EUR | /mois | +| Annuel | 1430 EUR | /an (~20% de réduction) | + +#### Enterprise +| Type | Prix | Récurrence | +|------|------|------------| +| Mensuel | Prix personnalisé | /mois | +| Annuel | Prix personnalisé | /an | + +### 2.3 Récupérer les Price IDs + +Après avoir créé les prix, notez les **Price IDs** (format: `price_xxxxx`) pour chaque prix. + +## 3. Configuration du Webhook + +### 3.1 Créer le Webhook Endpoint + +1. Allez dans **Developers > Webhooks** +2. Cliquez sur **Add endpoint** +3. Configurez : + - **Endpoint URL**: `https://votre-domaine.com/api/v1/subscriptions/webhook` + - **Events to send**: Sélectionnez les événements suivants : + - `checkout.session.completed` + - `customer.subscription.created` + - `customer.subscription.updated` + - `customer.subscription.deleted` + - `invoice.payment_failed` + +4. Cliquez sur **Add endpoint** +5. Notez le **Webhook signing secret** (format: `whsec_xxxxx`) + +### 3.2 Test Local avec Stripe CLI + +Pour tester les webhooks en local : + +```bash +# Installer Stripe CLI +brew install stripe/stripe-cli/stripe + +# Se connecter à Stripe +stripe login + +# Écouter les webhooks et les transférer en local +stripe listen --forward-to localhost:4000/api/v1/subscriptions/webhook + +# Le CLI affichera le webhook secret à utiliser localement +# > Ready! Your webhook signing secret is whsec_xxxxx +``` + +## 4. Variables d'environnement + +Ajoutez ces variables dans votre fichier `.env` du backend : + +```bash +# Clés API Stripe +STRIPE_SECRET_KEY=sk_test_xxxxx # Clé secrète (test ou live) +STRIPE_WEBHOOK_SECRET=whsec_xxxxx # Secret du webhook + +# Price IDs (créés dans le Dashboard) +STRIPE_STARTER_MONTHLY_PRICE_ID=price_xxxxx +STRIPE_STARTER_YEARLY_PRICE_ID=price_xxxxx +STRIPE_PRO_MONTHLY_PRICE_ID=price_xxxxx +STRIPE_PRO_YEARLY_PRICE_ID=price_xxxxx +STRIPE_ENTERPRISE_MONTHLY_PRICE_ID=price_xxxxx +STRIPE_ENTERPRISE_YEARLY_PRICE_ID=price_xxxxx + +# URL Frontend (pour les redirections) +FRONTEND_URL=http://localhost:3000 +``` + +## 5. Configurer le Customer Portal + +Le Customer Portal permet aux clients de gérer leur abonnement (changer de plan, annuler, mettre à jour le paiement). + +1. Allez dans **Settings > Billing > Customer portal** +2. Activez les options souhaitées : + - [x] Allow customers to update their payment methods + - [x] Allow customers to update subscriptions + - [x] Allow customers to cancel subscriptions + - [x] Show invoice history + +3. Configurez les produits autorisés dans le portal + +## 6. Mode Test vs Production + +### Mode Test (Développement) +- Utilisez `sk_test_xxxxx` comme clé secrète +- Les paiements ne sont pas réels +- Utilisez les cartes de test Stripe : + - Succès: `4242 4242 4242 4242` + - Échec: `4000 0000 0000 0002` + - 3D Secure: `4000 0025 0000 3155` + +### Mode Production +- Utilisez `sk_live_xxxxx` comme clé secrète +- Activez le mode live dans le Dashboard +- Assurez-vous d'avoir complété la vérification de compte + +## 7. Flux d'abonnement + +``` +┌─────────────────┐ +│ Page Subscription│ +│ (Frontend) │ +└────────┬────────┘ + │ Clic "Upgrade" + ▼ +┌─────────────────┐ +│ POST /checkout │ +│ (Backend) │ +└────────┬────────┘ + │ Crée Checkout Session + ▼ +┌─────────────────┐ +│ Stripe Checkout │ +│ (Stripe) │ +└────────┬────────┘ + │ Paiement réussi + ▼ +┌─────────────────┐ +│ Webhook │ +│ checkout. │ +│ session.completed│ +└────────┬────────┘ + │ Met à jour la subscription + ▼ +┌─────────────────┐ +│ Base de données │ +│ (PostgreSQL) │ +└─────────────────┘ +``` + +## 8. Gestion des erreurs + +### Paiement échoué +- Le webhook `invoice.payment_failed` est déclenché +- L'abonnement passe en statut `PAST_DUE` +- L'utilisateur est informé et peut mettre à jour son moyen de paiement + +### Annulation +- Via le Customer Portal ou l'API +- L'abonnement reste actif jusqu'à la fin de la période +- À la fin de la période, le webhook `customer.subscription.deleted` est déclenché +- L'organisation repasse au plan FREE + +## 9. Vérification + +### Checklist de configuration + +- [ ] Produits créés dans Stripe Dashboard +- [ ] Prix créés (mensuel + annuel pour chaque plan) +- [ ] Webhook endpoint configuré +- [ ] Customer Portal configuré +- [ ] Variables d'environnement ajoutées au `.env` +- [ ] Test avec Stripe CLI en local +- [ ] Test d'un paiement complet (checkout → webhook) + +### Test manuel + +1. Lancez le backend et le frontend +2. Connectez-vous en tant qu'ADMIN +3. Allez dans Settings > Subscription +4. Cliquez sur "Upgrade" sur un plan payant +5. Utilisez la carte de test `4242 4242 4242 4242` +6. Vérifiez que le plan est mis à jour dans la base de données +7. Vérifiez que les licences sont correctement comptées + +## 10. Commandes utiles + +```bash +# Voir les webhooks reçus +stripe events list --limit 10 + +# Déclencher un webhook manuellement +stripe trigger checkout.session.completed + +# Voir les logs +stripe logs tail +``` + +## Support + +Pour toute question sur Stripe : +- Documentation Stripe : https://stripe.com/docs +- Support Stripe : https://support.stripe.com