From 420e52311cb7242f3a814ca3c4bfa5d162b463a5 Mon Sep 17 00:00:00 2001 From: David Date: Thu, 19 Mar 2026 19:04:31 +0100 Subject: [PATCH] fix system payment and other missing --- .../src/application/admin/admin.module.ts | 18 +- .../src/application/auth/auth.service.ts | 33 +- .../controllers/admin.controller.ts | 193 ++++- .../controllers/csv-bookings.controller.ts | 75 +- .../controllers/invitations.controller.ts | 3 +- .../controllers/subscriptions.controller.ts | 28 +- .../src/application/csv-bookings.module.ts | 16 +- .../src/application/dto/auth-login.dto.ts | 12 + .../src/application/dto/organization.dto.ts | 32 + .../application/guards/feature-flag.guard.ts | 5 + .../mappers/organization.mapper.ts | 3 + .../services/csv-booking.service.ts | 186 ++++- .../services/invitation.service.ts | 5 +- .../services/subscription.service.ts | 39 +- .../src/domain/entities/csv-booking.entity.ts | 33 + .../entities/csv-booking.orm-entity.ts | 4 +- ...0000000005-AddPendingBankTransferStatus.ts | 75 ++ .../app/dashboard/admin/bookings/page.tsx | 721 ++++++++---------- .../dashboard/admin/organizations/page.tsx | 132 +++- .../app/dashboard/booking/[id]/pay/page.tsx | 467 ++++++++---- apps/frontend/app/dashboard/bookings/page.tsx | 28 +- apps/frontend/app/page.tsx | 332 ++++++-- apps/frontend/app/register/page.tsx | 26 + .../src/components/layout/LandingFooter.tsx | 15 - .../src/components/layout/LandingHeader.tsx | 6 +- .../components/organization/LicensesTab.tsx | 1 - apps/frontend/src/lib/api/admin.ts | 43 ++ apps/frontend/src/lib/api/bookings.ts | 8 + apps/frontend/src/types/api.ts | 2 + 29 files changed, 1825 insertions(+), 716 deletions(-) create mode 100644 apps/backend/src/infrastructure/persistence/typeorm/migrations/1740000000005-AddPendingBankTransferStatus.ts diff --git a/apps/backend/src/application/admin/admin.module.ts b/apps/backend/src/application/admin/admin.module.ts index 4fa96e8..d3435ad 100644 --- a/apps/backend/src/application/admin/admin.module.ts +++ b/apps/backend/src/application/admin/admin.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { ConfigModule } from '@nestjs/config'; // Controller import { AdminController } from '../controllers/admin.controller'; @@ -18,6 +19,13 @@ import { TypeOrmCsvBookingRepository } from '@infrastructure/persistence/typeorm import { USER_REPOSITORY } from '@domain/ports/out/user.repository'; import { ORGANIZATION_REPOSITORY } from '@domain/ports/out/organization.repository'; +// SIRET verification +import { SIRET_VERIFICATION_PORT } from '@domain/ports/out/siret-verification.port'; +import { PappersSiretAdapter } from '@infrastructure/external/pappers-siret.adapter'; + +// CSV Booking Service +import { CsvBookingsModule } from '../csv-bookings.module'; + /** * Admin Module * @@ -25,7 +33,11 @@ import { ORGANIZATION_REPOSITORY } from '@domain/ports/out/organization.reposito * All endpoints require ADMIN role. */ @Module({ - imports: [TypeOrmModule.forFeature([UserOrmEntity, OrganizationOrmEntity, CsvBookingOrmEntity])], + imports: [ + TypeOrmModule.forFeature([UserOrmEntity, OrganizationOrmEntity, CsvBookingOrmEntity]), + ConfigModule, + CsvBookingsModule, + ], controllers: [AdminController], providers: [ { @@ -37,6 +49,10 @@ import { ORGANIZATION_REPOSITORY } from '@domain/ports/out/organization.reposito useClass: TypeOrmOrganizationRepository, }, TypeOrmCsvBookingRepository, + { + provide: SIRET_VERIFICATION_PORT, + useClass: PappersSiretAdapter, + }, ], }) export class AdminModule {} diff --git a/apps/backend/src/application/auth/auth.service.ts b/apps/backend/src/application/auth/auth.service.ts index 2330fcf..aa0c892 100644 --- a/apps/backend/src/application/auth/auth.service.ts +++ b/apps/backend/src/application/auth/auth.service.ts @@ -222,17 +222,31 @@ export class AuthService { * Generate access and refresh tokens */ private async generateTokens(user: User): Promise<{ accessToken: string; refreshToken: string }> { - // Fetch subscription plan for JWT payload + // ADMIN users always get PLATINIUM plan with no expiration let plan = 'BRONZE'; let planFeatures: string[] = []; - try { - const subscription = await this.subscriptionService.getOrCreateSubscription( - user.organizationId - ); - plan = subscription.plan.value; - planFeatures = [...subscription.plan.planFeatures]; - } catch (error) { - this.logger.warn(`Failed to fetch subscription for JWT: ${error}`); + + if (user.role === UserRole.ADMIN) { + plan = 'PLATINIUM'; + planFeatures = [ + 'dashboard', + 'wiki', + 'user_management', + 'csv_export', + 'api_access', + 'custom_interface', + 'dedicated_kam', + ]; + } else { + try { + const subscription = await this.subscriptionService.getOrCreateSubscription( + user.organizationId + ); + plan = subscription.plan.value; + planFeatures = [...subscription.plan.planFeatures]; + } catch (error) { + this.logger.warn(`Failed to fetch subscription for JWT: ${error}`); + } } const accessPayload: JwtPayload = { @@ -321,6 +335,7 @@ export class AuthService { name: organizationData.name, type: organizationData.type, scac: organizationData.scac, + siren: organizationData.siren, address: { street: organizationData.street, city: organizationData.city, diff --git a/apps/backend/src/application/controllers/admin.controller.ts b/apps/backend/src/application/controllers/admin.controller.ts index 218aa9d..b1f0666 100644 --- a/apps/backend/src/application/controllers/admin.controller.ts +++ b/apps/backend/src/application/controllers/admin.controller.ts @@ -1,6 +1,7 @@ import { Controller, Get, + Post, Patch, Delete, Param, @@ -44,6 +45,13 @@ import { OrganizationResponseDto, OrganizationListResponseDto } from '../dto/org // CSV Booking imports import { TypeOrmCsvBookingRepository } from '@infrastructure/persistence/typeorm/repositories/csv-booking.repository'; +import { CsvBookingService } from '../services/csv-booking.service'; + +// SIRET verification imports +import { + SiretVerificationPort, + SIRET_VERIFICATION_PORT, +} from '@domain/ports/out/siret-verification.port'; /** * Admin Controller @@ -65,7 +73,10 @@ export class AdminController { @Inject(USER_REPOSITORY) private readonly userRepository: UserRepository, @Inject(ORGANIZATION_REPOSITORY) private readonly organizationRepository: OrganizationRepository, - private readonly csvBookingRepository: TypeOrmCsvBookingRepository + private readonly csvBookingRepository: TypeOrmCsvBookingRepository, + private readonly csvBookingService: CsvBookingService, + @Inject(SIRET_VERIFICATION_PORT) + private readonly siretVerificationPort: SiretVerificationPort ) {} // ==================== USERS ENDPOINTS ==================== @@ -329,6 +340,163 @@ export class AdminController { return OrganizationMapper.toDto(organization); } + /** + * Verify SIRET number for an organization (admin only) + * + * Calls Pappers API to verify the SIRET, then marks the organization as verified. + */ + @Post('organizations/:id/verify-siret') + @UsePipes(new ValidationPipe({ transform: true, whitelist: true })) + @ApiOperation({ + summary: 'Verify organization SIRET (Admin only)', + description: + 'Verify the SIRET number of an organization via Pappers API and mark it as verified. Required before the organization can make purchases.', + }) + @ApiParam({ + name: 'id', + description: 'Organization ID (UUID)', + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'SIRET verification result', + schema: { + type: 'object', + properties: { + verified: { type: 'boolean' }, + companyName: { type: 'string' }, + address: { type: 'string' }, + message: { type: 'string' }, + }, + }, + }) + @ApiNotFoundResponse({ + description: 'Organization not found', + }) + async verifySiret(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: UserPayload) { + this.logger.log(`[ADMIN: ${user.email}] Verifying SIRET for organization: ${id}`); + + const organization = await this.organizationRepository.findById(id); + if (!organization) { + throw new NotFoundException(`Organization ${id} not found`); + } + + const siret = organization.siret; + if (!siret) { + throw new BadRequestException( + 'Organization has no SIRET number. Please set a SIRET number before verification.' + ); + } + + const result = await this.siretVerificationPort.verify(siret); + + if (!result.valid) { + this.logger.warn(`[ADMIN] SIRET verification failed for ${siret}`); + return { + verified: false, + message: `Le numero SIRET ${siret} est invalide ou introuvable.`, + }; + } + + // Mark as verified and save + organization.markSiretVerified(); + await this.organizationRepository.update(organization); + + this.logger.log(`[ADMIN] SIRET verified successfully for organization: ${id}`); + + return { + verified: true, + companyName: result.companyName, + address: result.address, + message: `SIRET ${siret} verifie avec succes.`, + }; + } + + /** + * Manually approve SIRET/SIREN for an organization (admin only) + * + * Marks the organization's SIRET as verified without calling the external API. + */ + @Post('organizations/:id/approve-siret') + @ApiOperation({ + summary: 'Approve SIRET/SIREN (Admin only)', + description: + 'Manually approve the SIRET/SIREN of an organization. Marks it as verified without calling Pappers API.', + }) + @ApiParam({ name: 'id', description: 'Organization ID (UUID)' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'SIRET approved successfully', + }) + @ApiNotFoundResponse({ description: 'Organization not found' }) + async approveSiret(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: UserPayload) { + this.logger.log(`[ADMIN: ${user.email}] Manually approving SIRET for organization: ${id}`); + + const organization = await this.organizationRepository.findById(id); + if (!organization) { + throw new NotFoundException(`Organization ${id} not found`); + } + + if (!organization.siret && !organization.siren) { + throw new BadRequestException( + "L'organisation n'a ni SIRET ni SIREN. Veuillez en renseigner un avant l'approbation." + ); + } + + organization.markSiretVerified(); + await this.organizationRepository.update(organization); + + this.logger.log(`[ADMIN] SIRET manually approved for organization: ${id}`); + + return { + approved: true, + message: 'SIRET/SIREN approuve manuellement avec succes.', + organizationId: id, + organizationName: organization.name, + }; + } + + /** + * Reject SIRET/SIREN for an organization (admin only) + * + * Resets the verification flag to false. + */ + @Post('organizations/:id/reject-siret') + @ApiOperation({ + summary: 'Reject SIRET/SIREN (Admin only)', + description: + 'Reject the SIRET/SIREN of an organization. Resets the verification status to unverified.', + }) + @ApiParam({ name: 'id', description: 'Organization ID (UUID)' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'SIRET rejected successfully', + }) + @ApiNotFoundResponse({ description: 'Organization not found' }) + async rejectSiret(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: UserPayload) { + this.logger.log(`[ADMIN: ${user.email}] Rejecting SIRET for organization: ${id}`); + + const organization = await this.organizationRepository.findById(id); + if (!organization) { + throw new NotFoundException(`Organization ${id} not found`); + } + + // Reset SIRET verification to false by updating the SIRET (which resets siretVerified) + // If no SIRET, just update directly + if (organization.siret) { + organization.updateSiret(organization.siret); // This resets siretVerified to false + } + await this.organizationRepository.update(organization); + + this.logger.log(`[ADMIN] SIRET rejected for organization: ${id}`); + + return { + rejected: true, + message: "SIRET/SIREN rejete. L'organisation ne pourra pas effectuer d'achats.", + organizationId: id, + organizationName: organization.name, + }; + } + // ==================== CSV BOOKINGS ENDPOINTS ==================== /** @@ -440,6 +608,28 @@ export class AdminController { return this.csvBookingToDto(updatedBooking); } + /** + * Validate bank transfer for a booking (admin only) + * + * Transitions booking from PENDING_BANK_TRANSFER → PENDING and sends email to carrier + */ + @Post('bookings/:id/validate-transfer') + @ApiOperation({ + summary: 'Validate bank transfer (Admin only)', + description: + 'Admin confirms that the bank wire transfer has been received. Activates the booking and sends email to carrier.', + }) + @ApiParam({ name: 'id', description: 'Booking ID (UUID)' }) + @ApiResponse({ status: 200, description: 'Bank transfer validated, booking activated' }) + @ApiNotFoundResponse({ description: 'Booking not found' }) + async validateBankTransfer( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: UserPayload + ) { + this.logger.log(`[ADMIN: ${user.email}] Validating bank transfer for booking: ${id}`); + return this.csvBookingService.validateBankTransfer(id); + } + /** * Delete csv booking (admin only) */ @@ -483,6 +673,7 @@ export class AdminController { return { id: booking.id, + bookingNumber: booking.bookingNumber || null, userId: booking.userId, organizationId: booking.organizationId, carrierName: booking.carrierName, diff --git a/apps/backend/src/application/controllers/csv-bookings.controller.ts b/apps/backend/src/application/controllers/csv-bookings.controller.ts index 01e9aed..07a19ca 100644 --- a/apps/backend/src/application/controllers/csv-bookings.controller.ts +++ b/apps/backend/src/application/controllers/csv-bookings.controller.ts @@ -12,6 +12,7 @@ import { UploadedFiles, Request, BadRequestException, + ForbiddenException, ParseIntPipe, DefaultValuePipe, Inject, @@ -36,6 +37,10 @@ import { ShipmentCounterPort, SHIPMENT_COUNTER_PORT, } from '@domain/ports/out/shipment-counter.port'; +import { + OrganizationRepository, + ORGANIZATION_REPOSITORY, +} from '@domain/ports/out/organization.repository'; import { ShipmentLimitExceededException } from '@domain/exceptions/shipment-limit-exceeded.exception'; import { CreateCsvBookingDto, @@ -61,7 +66,9 @@ export class CsvBookingsController { private readonly subscriptionService: SubscriptionService, private readonly configService: ConfigService, @Inject(SHIPMENT_COUNTER_PORT) - private readonly shipmentCounter: ShipmentCounterPort + private readonly shipmentCounter: ShipmentCounterPort, + @Inject(ORGANIZATION_REPOSITORY) + private readonly organizationRepository: OrganizationRepository ) {} // ============================================================================ @@ -157,17 +164,20 @@ export class CsvBookingsController { const userId = req.user.id; const organizationId = req.user.organizationId; - // Check shipment limit (Bronze plan = 12/year) - const subscription = await this.subscriptionService.getOrCreateSubscription(organizationId); - const maxShipments = subscription.plan.maxShipmentsPerYear; - if (maxShipments !== -1) { - const currentYear = new Date().getFullYear(); - const count = await this.shipmentCounter.countShipmentsForOrganizationInYear( - organizationId, - currentYear - ); - if (count >= maxShipments) { - throw new ShipmentLimitExceededException(organizationId, count, maxShipments); + // ADMIN users bypass shipment limits + if (req.user.role !== 'ADMIN') { + // Check shipment limit (Bronze plan = 12/year) + const subscription = await this.subscriptionService.getOrCreateSubscription(organizationId); + const maxShipments = subscription.plan.maxShipmentsPerYear; + if (maxShipments !== -1) { + const currentYear = new Date().getFullYear(); + const count = await this.shipmentCounter.countShipmentsForOrganizationInYear( + organizationId, + currentYear + ); + if (count >= maxShipments) { + throw new ShipmentLimitExceededException(organizationId, count, maxShipments); + } } } @@ -399,8 +409,20 @@ export class CsvBookingsController { async payCommission(@Param('id') id: string, @Request() req: any) { const userId = req.user.id; const userEmail = req.user.email; + const organizationId = req.user.organizationId; const frontendUrl = this.configService.get('FRONTEND_URL') || 'http://localhost:3000'; + // ADMIN users bypass SIRET verification + if (req.user.role !== 'ADMIN') { + // SIRET verification gate: organization must have a verified SIRET before paying + const organization = await this.organizationRepository.findById(organizationId); + if (!organization || !organization.siretVerified) { + throw new ForbiddenException( + 'Le numero SIRET de votre organisation doit etre verifie par un administrateur avant de pouvoir effectuer un paiement. Contactez votre administrateur.' + ); + } + } + return await this.csvBookingService.createCommissionPayment(id, userId, userEmail, frontendUrl); } @@ -447,6 +469,35 @@ export class CsvBookingsController { return await this.csvBookingService.confirmCommissionPayment(id, sessionId, userId); } + /** + * Declare bank transfer — user confirms they have sent the wire transfer + * + * POST /api/v1/csv-bookings/:id/declare-transfer + */ + @Post(':id/declare-transfer') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Declare bank transfer', + description: + 'User confirms they have sent the bank wire transfer. Transitions booking to PENDING_BANK_TRANSFER awaiting admin validation.', + }) + @ApiParam({ name: 'id', description: 'Booking ID (UUID)' }) + @ApiResponse({ + status: 200, + description: 'Bank transfer declared, booking awaiting admin validation', + type: CsvBookingResponseDto, + }) + @ApiResponse({ status: 400, description: 'Booking not in PENDING_PAYMENT status' }) + @ApiResponse({ status: 404, description: 'Booking not found' }) + async declareTransfer( + @Param('id') id: string, + @Request() req: any + ): Promise { + const userId = req.user.id; + return await this.csvBookingService.declareBankTransfer(id, userId); + } + // ============================================================================ // PARAMETERIZED ROUTES (must come LAST) // ============================================================================ diff --git a/apps/backend/src/application/controllers/invitations.controller.ts b/apps/backend/src/application/controllers/invitations.controller.ts index 40436d2..57b01b1 100644 --- a/apps/backend/src/application/controllers/invitations.controller.ts +++ b/apps/backend/src/application/controllers/invitations.controller.ts @@ -71,7 +71,8 @@ export class InvitationsController { dto.lastName, dto.role as unknown as UserRole, user.organizationId, - user.id + user.id, + user.role ); return { diff --git a/apps/backend/src/application/controllers/subscriptions.controller.ts b/apps/backend/src/application/controllers/subscriptions.controller.ts index 0c7fb5c..bc806d1 100644 --- a/apps/backend/src/application/controllers/subscriptions.controller.ts +++ b/apps/backend/src/application/controllers/subscriptions.controller.ts @@ -22,6 +22,8 @@ import { Headers, RawBodyRequest, Req, + Inject, + ForbiddenException, } from '@nestjs/common'; import { ApiTags, @@ -47,13 +49,21 @@ 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'; +import { + OrganizationRepository, + ORGANIZATION_REPOSITORY, +} from '@domain/ports/out/organization.repository'; @ApiTags('Subscriptions') @Controller('subscriptions') export class SubscriptionsController { private readonly logger = new Logger(SubscriptionsController.name); - constructor(private readonly subscriptionService: SubscriptionService) {} + constructor( + private readonly subscriptionService: SubscriptionService, + @Inject(ORGANIZATION_REPOSITORY) + private readonly organizationRepository: OrganizationRepository + ) {} /** * Get subscription overview for current organization @@ -80,7 +90,7 @@ export class SubscriptionsController { @CurrentUser() user: UserPayload ): Promise { this.logger.log(`[User: ${user.email}] Getting subscription overview`); - return this.subscriptionService.getSubscriptionOverview(user.organizationId); + return this.subscriptionService.getSubscriptionOverview(user.organizationId, user.role); } /** @@ -126,7 +136,7 @@ export class SubscriptionsController { }) async canInvite(@CurrentUser() user: UserPayload): Promise { this.logger.log(`[User: ${user.email}] Checking license availability`); - return this.subscriptionService.canInviteUser(user.organizationId); + return this.subscriptionService.canInviteUser(user.organizationId, user.role); } /** @@ -159,6 +169,18 @@ export class SubscriptionsController { @CurrentUser() user: UserPayload ): Promise { this.logger.log(`[User: ${user.email}] Creating checkout session for plan: ${dto.plan}`); + + // ADMIN users bypass all payment restrictions + if (user.role !== 'ADMIN') { + // SIRET verification gate: organization must have a verified SIRET before purchasing + const organization = await this.organizationRepository.findById(user.organizationId); + if (!organization || !organization.siretVerified) { + throw new ForbiddenException( + 'Le numero SIRET de votre organisation doit etre verifie par un administrateur avant de pouvoir effectuer un achat. Contactez votre administrateur.' + ); + } + } + return this.subscriptionService.createCheckoutSession(user.organizationId, user.id, dto); } diff --git a/apps/backend/src/application/csv-bookings.module.ts b/apps/backend/src/application/csv-bookings.module.ts index c83fb0e..6330924 100644 --- a/apps/backend/src/application/csv-bookings.module.ts +++ b/apps/backend/src/application/csv-bookings.module.ts @@ -8,6 +8,12 @@ import { CsvBookingOrmEntity } from '../infrastructure/persistence/typeorm/entit import { TypeOrmCsvBookingRepository } from '../infrastructure/persistence/typeorm/repositories/csv-booking.repository'; import { TypeOrmShipmentCounterRepository } from '../infrastructure/persistence/typeorm/repositories/shipment-counter.repository'; import { SHIPMENT_COUNTER_PORT } from '@domain/ports/out/shipment-counter.port'; +import { ORGANIZATION_REPOSITORY } from '@domain/ports/out/organization.repository'; +import { OrganizationOrmEntity } from '../infrastructure/persistence/typeorm/entities/organization.orm-entity'; +import { TypeOrmOrganizationRepository } from '../infrastructure/persistence/typeorm/repositories/typeorm-organization.repository'; +import { USER_REPOSITORY } from '@domain/ports/out/user.repository'; +import { UserOrmEntity } from '../infrastructure/persistence/typeorm/entities/user.orm-entity'; +import { TypeOrmUserRepository } from '../infrastructure/persistence/typeorm/repositories/typeorm-user.repository'; import { NotificationsModule } from './notifications/notifications.module'; import { EmailModule } from '../infrastructure/email/email.module'; import { StorageModule } from '../infrastructure/storage/storage.module'; @@ -21,7 +27,7 @@ import { StripeModule } from '../infrastructure/stripe/stripe.module'; */ @Module({ imports: [ - TypeOrmModule.forFeature([CsvBookingOrmEntity]), + TypeOrmModule.forFeature([CsvBookingOrmEntity, OrganizationOrmEntity, UserOrmEntity]), ConfigModule, NotificationsModule, EmailModule, @@ -37,6 +43,14 @@ import { StripeModule } from '../infrastructure/stripe/stripe.module'; provide: SHIPMENT_COUNTER_PORT, useClass: TypeOrmShipmentCounterRepository, }, + { + provide: ORGANIZATION_REPOSITORY, + useClass: TypeOrmOrganizationRepository, + }, + { + provide: USER_REPOSITORY, + useClass: TypeOrmUserRepository, + }, ], exports: [CsvBookingService, TypeOrmCsvBookingRepository], }) diff --git a/apps/backend/src/application/dto/auth-login.dto.ts b/apps/backend/src/application/dto/auth-login.dto.ts index 20aec51..c947601 100644 --- a/apps/backend/src/application/dto/auth-login.dto.ts +++ b/apps/backend/src/application/dto/auth-login.dto.ts @@ -94,6 +94,18 @@ export class RegisterOrganizationDto { @Matches(/^[A-Z]{2}$/, { message: 'Country must be a 2-letter ISO code (e.g., NL, US, CN)' }) country: string; + @ApiProperty({ + example: '123456789', + description: 'French SIREN number (9 digits, required)', + minLength: 9, + maxLength: 9, + }) + @IsString() + @MinLength(9, { message: 'SIREN must be exactly 9 digits' }) + @MaxLength(9, { message: 'SIREN must be exactly 9 digits' }) + @Matches(/^[0-9]{9}$/, { message: 'SIREN must be 9 digits' }) + siren: string; + @ApiPropertyOptional({ example: 'MAEU', description: 'Standard Carrier Alpha Code (4 uppercase letters, required for carriers only)', diff --git a/apps/backend/src/application/dto/organization.dto.ts b/apps/backend/src/application/dto/organization.dto.ts index 881322c..130a53b 100644 --- a/apps/backend/src/application/dto/organization.dto.ts +++ b/apps/backend/src/application/dto/organization.dto.ts @@ -184,6 +184,19 @@ export class UpdateOrganizationDto { @Matches(/^[0-9]{9}$/, { message: 'SIREN must be 9 digits' }) siren?: string; + @ApiPropertyOptional({ + example: '12345678901234', + description: 'French SIRET number (14 digits)', + minLength: 14, + maxLength: 14, + }) + @IsString() + @IsOptional() + @MinLength(14) + @MaxLength(14) + @Matches(/^[0-9]{14}$/, { message: 'SIRET must be 14 digits' }) + siret?: string; + @ApiPropertyOptional({ example: 'FR123456789', description: 'EU EORI number', @@ -344,6 +357,25 @@ export class OrganizationResponseDto { }) documents: OrganizationDocumentDto[]; + @ApiPropertyOptional({ + example: '12345678901234', + description: 'French SIRET number (14 digits)', + }) + siret?: string; + + @ApiProperty({ + example: false, + description: 'Whether the SIRET has been verified by an admin', + }) + siretVerified: boolean; + + @ApiPropertyOptional({ + example: 'none', + description: 'Organization status badge', + enum: ['none', 'silver', 'gold', 'platinium'], + }) + statusBadge?: string; + @ApiProperty({ example: true, description: 'Active status', diff --git a/apps/backend/src/application/guards/feature-flag.guard.ts b/apps/backend/src/application/guards/feature-flag.guard.ts index 5aae493..d769ac4 100644 --- a/apps/backend/src/application/guards/feature-flag.guard.ts +++ b/apps/backend/src/application/guards/feature-flag.guard.ts @@ -53,6 +53,11 @@ export class FeatureFlagGuard implements CanActivate { return false; } + // ADMIN users have full access to all features — no plan check needed + if (user.role === 'ADMIN') { + return true; + } + // Fast path: check plan features from JWT payload if (user.planFeatures && Array.isArray(user.planFeatures)) { const hasAllFeatures = requiredFeatures.every(feature => user.planFeatures.includes(feature)); diff --git a/apps/backend/src/application/mappers/organization.mapper.ts b/apps/backend/src/application/mappers/organization.mapper.ts index 8405e33..8280ab1 100644 --- a/apps/backend/src/application/mappers/organization.mapper.ts +++ b/apps/backend/src/application/mappers/organization.mapper.ts @@ -31,6 +31,9 @@ export class OrganizationMapper { address: this.mapAddressToDto(organization.address), logoUrl: organization.logoUrl, documents: organization.documents.map(doc => this.mapDocumentToDto(doc)), + siret: organization.siret, + siretVerified: organization.siretVerified, + statusBadge: organization.statusBadge, isActive: organization.isActive, createdAt: organization.createdAt, updatedAt: organization.updatedAt, diff --git a/apps/backend/src/application/services/csv-booking.service.ts b/apps/backend/src/application/services/csv-booking.service.ts index 3ca997b..9064a7a 100644 --- a/apps/backend/src/application/services/csv-booking.service.ts +++ b/apps/backend/src/application/services/csv-booking.service.ts @@ -16,6 +16,7 @@ import { NOTIFICATION_REPOSITORY, } from '@domain/ports/out/notification.repository'; import { EmailPort, EMAIL_PORT } from '@domain/ports/out/email.port'; +import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.repository'; import { StoragePort, STORAGE_PORT } from '@domain/ports/out/storage.port'; import { StripePort, STRIPE_PORT } from '@domain/ports/out/stripe.port'; import { @@ -67,7 +68,9 @@ export class CsvBookingService { private readonly storageAdapter: StoragePort, @Inject(STRIPE_PORT) private readonly stripeAdapter: StripePort, - private readonly subscriptionService: SubscriptionService + private readonly subscriptionService: SubscriptionService, + @Inject(USER_REPOSITORY) + private readonly userRepository: UserRepository ) {} /** @@ -341,6 +344,187 @@ export class CsvBookingService { return this.toResponseDto(updatedBooking); } + /** + * Declare bank transfer — user confirms they have sent the wire transfer + * Transitions booking from PENDING_PAYMENT → PENDING_BANK_TRANSFER + * Sends an email notification to all ADMIN users + */ + async declareBankTransfer(bookingId: string, userId: string): Promise { + const booking = await this.csvBookingRepository.findById(bookingId); + + if (!booking) { + throw new NotFoundException(`Booking with ID ${bookingId} not found`); + } + + if (booking.userId !== userId) { + throw new NotFoundException(`Booking with ID ${bookingId} not found`); + } + + if (booking.status !== CsvBookingStatus.PENDING_PAYMENT) { + throw new BadRequestException( + `Booking is not awaiting payment. Current status: ${booking.status}` + ); + } + + // Get booking number before update + const ormBooking = await this.csvBookingRepository['repository'].findOne({ + where: { id: bookingId }, + }); + const bookingNumber = ormBooking?.bookingNumber || bookingId.slice(0, 8).toUpperCase(); + + booking.markBankTransferDeclared(); + const updatedBooking = await this.csvBookingRepository.update(booking); + this.logger.log(`Booking ${bookingId} bank transfer declared, status now PENDING_BANK_TRANSFER`); + + // Send email to all ADMIN users + try { + const allUsers = await this.userRepository.findAll(); + const adminEmails = allUsers + .filter(u => u.role === 'ADMIN' && u.isActive) + .map(u => u.email); + + if (adminEmails.length > 0) { + const commissionAmount = booking.commissionAmountEur + ? new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(booking.commissionAmountEur) + : 'N/A'; + + await this.emailAdapter.send({ + to: adminEmails, + subject: `[XPEDITIS] Virement à valider — ${bookingNumber}`, + html: ` +
+

Nouveau virement à valider

+

Un client a déclaré avoir effectué un virement bancaire pour le booking suivant :

+ + + + + + + + + + + + + + + + + +
Numéro de booking${bookingNumber}
Transporteur${booking.carrierName}
Trajet${booking.getRouteDescription()}
Montant commission${commissionAmount}
+

Rendez-vous dans la console d'administration pour valider ce virement et activer le booking.

+ + Voir les bookings en attente + +
+ `, + }); + this.logger.log(`Admin notification email sent to: ${adminEmails.join(', ')}`); + } + } catch (error: any) { + this.logger.error(`Failed to send admin notification email: ${error?.message}`, error?.stack); + } + + // In-app notification for the user + try { + const notification = Notification.create({ + id: uuidv4(), + userId: booking.userId, + organizationId: booking.organizationId, + type: NotificationType.BOOKING_UPDATED, + priority: NotificationPriority.MEDIUM, + title: 'Virement déclaré', + message: `Votre virement pour le booking ${bookingNumber} a été enregistré. Un administrateur va vérifier la réception et activer votre booking.`, + metadata: { bookingId: booking.id }, + }); + await this.notificationRepository.save(notification); + } catch (error: any) { + this.logger.error(`Failed to create user notification: ${error?.message}`, error?.stack); + } + + return this.toResponseDto(updatedBooking); + } + + /** + * Admin validates bank transfer — confirms receipt and activates booking + * Transitions booking from PENDING_BANK_TRANSFER → PENDING then sends email to carrier + */ + async validateBankTransfer(bookingId: string): Promise { + const booking = await this.csvBookingRepository.findById(bookingId); + + if (!booking) { + throw new NotFoundException(`Booking with ID ${bookingId} not found`); + } + + if (booking.status !== CsvBookingStatus.PENDING_BANK_TRANSFER) { + throw new BadRequestException( + `Booking is not awaiting bank transfer validation. Current status: ${booking.status}` + ); + } + + booking.markBankTransferValidated(); + const updatedBooking = await this.csvBookingRepository.update(booking); + this.logger.log(`Booking ${bookingId} bank transfer validated by admin, status now PENDING`); + + // Get booking number for email + const ormBooking = await this.csvBookingRepository['repository'].findOne({ + where: { id: bookingId }, + }); + const bookingNumber = ormBooking?.bookingNumber; + const documentPassword = bookingNumber + ? this.extractPasswordFromBookingNumber(bookingNumber) + : undefined; + + // Send email to carrier + try { + await this.emailAdapter.sendCsvBookingRequest(booking.carrierEmail, { + bookingId: booking.id, + bookingNumber: bookingNumber || '', + documentPassword: documentPassword || '', + origin: booking.origin.getValue(), + destination: booking.destination.getValue(), + volumeCBM: booking.volumeCBM, + weightKG: booking.weightKG, + palletCount: booking.palletCount, + priceUSD: booking.priceUSD, + priceEUR: booking.priceEUR, + primaryCurrency: booking.primaryCurrency, + transitDays: booking.transitDays, + containerType: booking.containerType, + documents: booking.documents.map(doc => ({ + type: doc.type, + fileName: doc.fileName, + })), + confirmationToken: booking.confirmationToken, + notes: booking.notes, + }); + this.logger.log(`Email sent to carrier after bank transfer validation: ${booking.carrierEmail}`); + } catch (error: any) { + this.logger.error(`Failed to send email to carrier: ${error?.message}`, error?.stack); + } + + // In-app notification for the user + try { + const notification = Notification.create({ + id: uuidv4(), + userId: booking.userId, + organizationId: booking.organizationId, + type: NotificationType.BOOKING_CONFIRMED, + priority: NotificationPriority.HIGH, + title: 'Virement validé — Booking activé', + message: `Votre virement pour le booking ${bookingNumber || booking.id.slice(0, 8)} a été confirmé. Votre demande auprès de ${booking.carrierName} a été transmise au transporteur.`, + metadata: { bookingId: booking.id }, + }); + await this.notificationRepository.save(notification); + } catch (error: any) { + this.logger.error(`Failed to create user notification: ${error?.message}`, error?.stack); + } + + return this.toResponseDto(updatedBooking); + } + /** * Get booking by ID * Accessible by: booking owner OR assigned carrier diff --git a/apps/backend/src/application/services/invitation.service.ts b/apps/backend/src/application/services/invitation.service.ts index e4b4a6a..ec95a65 100644 --- a/apps/backend/src/application/services/invitation.service.ts +++ b/apps/backend/src/application/services/invitation.service.ts @@ -50,7 +50,8 @@ export class InvitationService { lastName: string, role: UserRole, organizationId: string, - invitedById: string + invitedById: string, + inviterRole?: string ): Promise { this.logger.log(`Creating invitation for ${email} in organization ${organizationId}`); @@ -69,7 +70,7 @@ export class InvitationService { } // Check if licenses are available for this organization - const canInviteResult = await this.subscriptionService.canInviteUser(organizationId); + const canInviteResult = await this.subscriptionService.canInviteUser(organizationId, inviterRole); if (!canInviteResult.canInvite) { this.logger.warn( `License limit reached for organization ${organizationId}: ${canInviteResult.usedLicenses}/${canInviteResult.maxLicenses}` diff --git a/apps/backend/src/application/services/subscription.service.ts b/apps/backend/src/application/services/subscription.service.ts index 061949f..255c0e3 100644 --- a/apps/backend/src/application/services/subscription.service.ts +++ b/apps/backend/src/application/services/subscription.service.ts @@ -60,8 +60,12 @@ export class SubscriptionService { /** * Get subscription overview for an organization + * ADMIN users always see a PLATINIUM plan with no expiration */ - async getSubscriptionOverview(organizationId: string): Promise { + async getSubscriptionOverview( + organizationId: string, + userRole?: string + ): Promise { const subscription = await this.getOrCreateSubscription(organizationId); const activeLicenses = await this.licenseRepository.findActiveBySubscriptionId(subscription.id); @@ -78,23 +82,27 @@ export class SubscriptionService { const usedLicenses = await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins( subscription.id ); - const maxLicenses = subscription.maxLicenses; - const availableLicenses = subscription.isUnlimited() + + // ADMIN users always have PLATINIUM plan with no expiration + const isAdmin = userRole === 'ADMIN'; + const effectivePlan = isAdmin ? SubscriptionPlan.platinium() : subscription.plan; + const maxLicenses = effectivePlan.maxLicenses; + const availableLicenses = effectivePlan.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), + plan: effectivePlan.value as SubscriptionPlanDto, + planDetails: this.mapPlanToDto(effectivePlan), status: subscription.status.value as SubscriptionStatusDto, usedLicenses, maxLicenses, availableLicenses, - cancelAtPeriodEnd: subscription.cancelAtPeriodEnd, - currentPeriodStart: subscription.currentPeriodStart || undefined, - currentPeriodEnd: subscription.currentPeriodEnd || undefined, + cancelAtPeriodEnd: false, + currentPeriodStart: isAdmin ? undefined : subscription.currentPeriodStart || undefined, + currentPeriodEnd: isAdmin ? undefined : subscription.currentPeriodEnd || undefined, createdAt: subscription.createdAt, updatedAt: subscription.updatedAt, licenses: enrichedLicenses, @@ -111,9 +119,20 @@ export class SubscriptionService { /** * Check if organization can invite more users - * Note: ADMIN users don't count against the license quota + * Note: ADMIN users don't count against the license quota and always have unlimited licenses */ - async canInviteUser(organizationId: string): Promise { + async canInviteUser(organizationId: string, userRole?: string): Promise { + // ADMIN users always have unlimited invitations + if (userRole === 'ADMIN') { + return { + canInvite: true, + availableLicenses: -1, + usedLicenses: 0, + maxLicenses: -1, + message: undefined, + }; + } + const subscription = await this.getOrCreateSubscription(organizationId); // Count only non-ADMIN licenses - ADMIN users have unlimited licenses const usedLicenses = await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins( diff --git a/apps/backend/src/domain/entities/csv-booking.entity.ts b/apps/backend/src/domain/entities/csv-booking.entity.ts index f23e797..ca0b7de 100644 --- a/apps/backend/src/domain/entities/csv-booking.entity.ts +++ b/apps/backend/src/domain/entities/csv-booking.entity.ts @@ -7,6 +7,7 @@ import { PortCode } from '../value-objects/port-code.vo'; */ export enum CsvBookingStatus { PENDING_PAYMENT = 'PENDING_PAYMENT', // Awaiting commission payment + PENDING_BANK_TRANSFER = 'PENDING_BANK_TRANSFER', // Bank transfer declared, awaiting admin validation PENDING = 'PENDING', // Awaiting carrier response ACCEPTED = 'ACCEPTED', // Carrier accepted the booking REJECTED = 'REJECTED', // Carrier rejected the booking @@ -171,6 +172,38 @@ export class CsvBooking { this.status = CsvBookingStatus.PENDING; } + /** + * Declare bank transfer → transition to PENDING_BANK_TRANSFER + * Called when user confirms they have sent the bank transfer + * + * @throws Error if booking is not in PENDING_PAYMENT status + */ + markBankTransferDeclared(): void { + if (this.status !== CsvBookingStatus.PENDING_PAYMENT) { + throw new Error( + `Cannot declare bank transfer for booking with status ${this.status}. Only PENDING_PAYMENT bookings can transition.` + ); + } + + this.status = CsvBookingStatus.PENDING_BANK_TRANSFER; + } + + /** + * Admin validates bank transfer → transition to PENDING + * Called by admin once bank transfer has been received and verified + * + * @throws Error if booking is not in PENDING_BANK_TRANSFER status + */ + markBankTransferValidated(): void { + if (this.status !== CsvBookingStatus.PENDING_BANK_TRANSFER) { + throw new Error( + `Cannot validate bank transfer for booking with status ${this.status}. Only PENDING_BANK_TRANSFER bookings can transition.` + ); + } + + this.status = CsvBookingStatus.PENDING; + } + /** * Accept the booking * diff --git a/apps/backend/src/infrastructure/persistence/typeorm/entities/csv-booking.orm-entity.ts b/apps/backend/src/infrastructure/persistence/typeorm/entities/csv-booking.orm-entity.ts index 63e0783..75eb591 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/entities/csv-booking.orm-entity.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/entities/csv-booking.orm-entity.ts @@ -75,11 +75,11 @@ export class CsvBookingOrmEntity { @Column({ name: 'status', type: 'enum', - enum: ['PENDING_PAYMENT', 'PENDING', 'ACCEPTED', 'REJECTED', 'CANCELLED'], + enum: ['PENDING_PAYMENT', 'PENDING_BANK_TRANSFER', 'PENDING', 'ACCEPTED', 'REJECTED', 'CANCELLED'], default: 'PENDING_PAYMENT', }) @Index() - status: 'PENDING_PAYMENT' | 'PENDING' | 'ACCEPTED' | 'REJECTED' | 'CANCELLED'; + status: 'PENDING_PAYMENT' | 'PENDING_BANK_TRANSFER' | 'PENDING' | 'ACCEPTED' | 'REJECTED' | 'CANCELLED'; @Column({ name: 'documents', type: 'jsonb' }) documents: Array<{ diff --git a/apps/backend/src/infrastructure/persistence/typeorm/migrations/1740000000005-AddPendingBankTransferStatus.ts b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1740000000005-AddPendingBankTransferStatus.ts new file mode 100644 index 0000000..870bf03 --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1740000000005-AddPendingBankTransferStatus.ts @@ -0,0 +1,75 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Migration: Add PENDING_BANK_TRANSFER status to csv_bookings enum + */ +export class AddPendingBankTransferStatus1740000000005 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // Drop default before modifying enum + await queryRunner.query(` + ALTER TABLE "csv_bookings" ALTER COLUMN "status" DROP DEFAULT + `); + + // Create new enum with PENDING_BANK_TRANSFER + await queryRunner.query(` + CREATE TYPE "csv_booking_status_new" AS ENUM ( + 'PENDING_PAYMENT', + 'PENDING_BANK_TRANSFER', + 'PENDING', + 'ACCEPTED', + 'REJECTED', + 'CANCELLED' + ) + `); + + // Swap column to new enum type + await queryRunner.query(` + ALTER TABLE "csv_bookings" + ALTER COLUMN "status" TYPE "csv_booking_status_new" + USING "status"::text::"csv_booking_status_new" + `); + + // Drop old enum and rename new + await queryRunner.query(`DROP TYPE "csv_booking_status"`); + await queryRunner.query(`ALTER TYPE "csv_booking_status_new" RENAME TO "csv_booking_status"`); + + // Restore default + await queryRunner.query(` + ALTER TABLE "csv_bookings" ALTER COLUMN "status" SET DEFAULT 'PENDING_PAYMENT' + `); + } + + public async down(queryRunner: QueryRunner): Promise { + // Move any PENDING_BANK_TRANSFER rows back to PENDING_PAYMENT + await queryRunner.query(` + UPDATE "csv_bookings" SET "status" = 'PENDING_PAYMENT' WHERE "status" = 'PENDING_BANK_TRANSFER' + `); + + await queryRunner.query(` + ALTER TABLE "csv_bookings" ALTER COLUMN "status" DROP DEFAULT + `); + + await queryRunner.query(` + CREATE TYPE "csv_booking_status_old" AS ENUM ( + 'PENDING_PAYMENT', + 'PENDING', + 'ACCEPTED', + 'REJECTED', + 'CANCELLED' + ) + `); + + await queryRunner.query(` + ALTER TABLE "csv_bookings" + ALTER COLUMN "status" TYPE "csv_booking_status_old" + USING "status"::text::"csv_booking_status_old" + `); + + await queryRunner.query(`DROP TYPE "csv_booking_status"`); + await queryRunner.query(`ALTER TYPE "csv_booking_status_old" RENAME TO "csv_booking_status"`); + + await queryRunner.query(` + ALTER TABLE "csv_bookings" ALTER COLUMN "status" SET DEFAULT 'PENDING_PAYMENT' + `); + } +} diff --git a/apps/frontend/app/dashboard/admin/bookings/page.tsx b/apps/frontend/app/dashboard/admin/bookings/page.tsx index 1bb2a1a..39ea9b6 100644 --- a/apps/frontend/app/dashboard/admin/bookings/page.tsx +++ b/apps/frontend/app/dashboard/admin/bookings/page.tsx @@ -1,413 +1,308 @@ -'use client'; - -import { useState, useEffect } from 'react'; -import { getAllBookings } from '@/lib/api/admin'; - -interface Booking { - id: string; - bookingNumber?: string; - bookingId?: string; - type?: string; - status: string; - // CSV bookings use these fields - origin?: string; - destination?: string; - carrierName?: string; - // Regular bookings use these fields - originPort?: { - code: string; - name: string; - }; - destinationPort?: { - code: string; - name: string; - }; - carrier?: string; - containerType: string; - quantity?: number; - price?: number; - primaryCurrency?: string; - totalPrice?: { - amount: number; - currency: string; - }; - createdAt?: string; - updatedAt?: string; - requestedAt?: string; - organizationId?: string; - userId?: string; -} - -export default function AdminBookingsPage() { - const [bookings, setBookings] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [selectedBooking, setSelectedBooking] = useState(null); - const [showDetailsModal, setShowDetailsModal] = useState(false); - const [filterStatus, setFilterStatus] = useState('all'); - const [searchTerm, setSearchTerm] = useState(''); - - // Helper function to get formatted quote number - const getQuoteNumber = (booking: Booking): string => { - if (booking.type === 'csv') { - return `#${booking.bookingId || booking.id.slice(0, 8).toUpperCase()}`; - } - return booking.bookingNumber || `#${booking.id.slice(0, 8).toUpperCase()}`; - }; - - useEffect(() => { - fetchBookings(); - }, []); - - const fetchBookings = async () => { - try { - setLoading(true); - const response = await getAllBookings(); - setBookings(response.bookings || []); - setError(null); - } catch (err: any) { - setError(err.message || 'Failed to load bookings'); - } finally { - setLoading(false); - } - }; - - const getStatusColor = (status: string) => { - const colors: Record = { - draft: 'bg-gray-100 text-gray-800', - pending: 'bg-yellow-100 text-yellow-800', - confirmed: 'bg-blue-100 text-blue-800', - in_transit: 'bg-purple-100 text-purple-800', - delivered: 'bg-green-100 text-green-800', - cancelled: 'bg-red-100 text-red-800', - }; - return colors[status.toLowerCase()] || 'bg-gray-100 text-gray-800'; - }; - - const filteredBookings = bookings - .filter(booking => filterStatus === 'all' || booking.status.toLowerCase() === filterStatus) - .filter(booking => { - if (searchTerm === '') return true; - const searchLower = searchTerm.toLowerCase(); - const quoteNumber = getQuoteNumber(booking).toLowerCase(); - return ( - quoteNumber.includes(searchLower) || - booking.bookingNumber?.toLowerCase().includes(searchLower) || - booking.carrier?.toLowerCase().includes(searchLower) || - booking.carrierName?.toLowerCase().includes(searchLower) || - booking.origin?.toLowerCase().includes(searchLower) || - booking.destination?.toLowerCase().includes(searchLower) - ); - }); - - if (loading) { - return ( -
-
-
-

Loading bookings...

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

Booking Management

-

- View and manage all bookings across the platform -

-
-
- - {/* 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" - /> -
-
- - -
-
-
- - {/* Stats Cards */} -
-
-
Total Réservations
-
{bookings.length}
-
-
-
En Attente
-
- {bookings.filter(b => b.status.toUpperCase() === 'PENDING').length} -
-
-
-
Acceptées
-
- {bookings.filter(b => b.status.toUpperCase() === 'ACCEPTED').length} -
-
-
-
Rejetées
-
- {bookings.filter(b => b.status.toUpperCase() === 'REJECTED').length} -
-
-
- - {/* Error Message */} - {error && ( -
- {error} -
- )} - - {/* Bookings Table */} -
- - - - - - - - - - - - - - {filteredBookings.map(booking => ( - - - - - - - - - - ))} - -
- Numéro de devis - - Route - - Transporteur - - Conteneur - - Statut - - Prix - - Actions -
-
- {getQuoteNumber(booking)} -
-
- {new Date(booking.createdAt || booking.requestedAt || '').toLocaleDateString()} -
-
-
- {booking.originPort ? `${booking.originPort.code} → ${booking.destinationPort?.code}` : `${booking.origin} → ${booking.destination}`} -
-
- {booking.originPort ? `${booking.originPort.name} → ${booking.destinationPort?.name}` : ''} -
-
- {booking.carrier || booking.carrierName || 'N/A'} - -
{booking.containerType}
-
- {booking.quantity ? `Qty: ${booking.quantity}` : ''} -
-
- - {booking.status} - - - {booking.totalPrice - ? `${booking.totalPrice.amount.toLocaleString()} ${booking.totalPrice.currency}` - : booking.price - ? `${booking.price.toLocaleString()} ${booking.primaryCurrency || 'USD'}` - : 'N/A' - } - - -
-
- - {/* Details Modal */} - {showDetailsModal && selectedBooking && ( -
-
-
-

Booking Details

- -
- -
-
-
- -
- {getQuoteNumber(selectedBooking)} -
-
-
- - - {selectedBooking.status} - -
-
- -
-

Route Information

-
-
- -
- {selectedBooking.originPort ? ( - <> -
{selectedBooking.originPort.code}
-
{selectedBooking.originPort.name}
- - ) : ( -
{selectedBooking.origin}
- )} -
-
-
- -
- {selectedBooking.destinationPort ? ( - <> -
{selectedBooking.destinationPort.code}
-
{selectedBooking.destinationPort.name}
- - ) : ( -
{selectedBooking.destination}
- )} -
-
-
-
- -
-

Shipping Details

-
-
- -
- {selectedBooking.carrier || selectedBooking.carrierName || 'N/A'} -
-
-
- -
{selectedBooking.containerType}
-
- {selectedBooking.quantity && ( -
- -
{selectedBooking.quantity}
-
- )} -
-
- -
-

Pricing

-
- {selectedBooking.totalPrice - ? `${selectedBooking.totalPrice.amount.toLocaleString()} ${selectedBooking.totalPrice.currency}` - : selectedBooking.price - ? `${selectedBooking.price.toLocaleString()} ${selectedBooking.primaryCurrency || 'USD'}` - : 'N/A' - } -
-
- -
-

Timeline

-
-
- -
- {new Date(selectedBooking.createdAt || selectedBooking.requestedAt || '').toLocaleString()} -
-
- {selectedBooking.updatedAt && ( -
- -
{new Date(selectedBooking.updatedAt).toLocaleString()}
-
- )} -
-
-
- -
- -
-
-
- )} -
- ); -} +'use client'; + +import { useState, useEffect } from 'react'; +import { getAllBookings, validateBankTransfer } from '@/lib/api/admin'; + +interface Booking { + id: string; + bookingNumber?: string | null; + type?: string; + status: string; + origin?: string; + destination?: string; + carrierName?: string; + containerType: string; + volumeCBM?: number; + weightKG?: number; + palletCount?: number; + priceEUR?: number; + priceUSD?: number; + primaryCurrency?: string; + createdAt?: string; + requestedAt?: string; + updatedAt?: string; + organizationId?: string; + userId?: string; +} + +export default function AdminBookingsPage() { + const [bookings, setBookings] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [filterStatus, setFilterStatus] = useState('all'); + const [searchTerm, setSearchTerm] = useState(''); + const [validatingId, setValidatingId] = useState(null); + + useEffect(() => { + fetchBookings(); + }, []); + + const handleValidateTransfer = async (bookingId: string) => { + if (!window.confirm('Confirmer la réception du virement et activer ce booking ?')) return; + setValidatingId(bookingId); + try { + await validateBankTransfer(bookingId); + await fetchBookings(); + } catch (err: any) { + setError(err.message || 'Erreur lors de la validation du virement'); + } finally { + setValidatingId(null); + } + }; + + const fetchBookings = async () => { + try { + setLoading(true); + const response = await getAllBookings(); + setBookings(response.bookings || []); + setError(null); + } catch (err: any) { + setError(err.message || 'Impossible de charger les réservations'); + } finally { + setLoading(false); + } + }; + + const getStatusColor = (status: string) => { + const colors: Record = { + pending_payment: 'bg-orange-100 text-orange-800', + pending_bank_transfer: 'bg-amber-100 text-amber-900', + pending: 'bg-yellow-100 text-yellow-800', + accepted: 'bg-green-100 text-green-800', + rejected: 'bg-red-100 text-red-800', + cancelled: 'bg-red-100 text-red-800', + }; + return colors[status.toLowerCase()] || 'bg-gray-100 text-gray-800'; + }; + + const getStatusLabel = (status: string) => { + const labels: Record = { + PENDING_PAYMENT: 'Paiement en attente', + PENDING_BANK_TRANSFER: 'Virement à valider', + PENDING: 'En attente transporteur', + ACCEPTED: 'Accepté', + REJECTED: 'Rejeté', + CANCELLED: 'Annulé', + }; + return labels[status.toUpperCase()] || status; + }; + + const getShortId = (booking: Booking) => `#${booking.id.slice(0, 8).toUpperCase()}`; + + const filteredBookings = bookings + .filter(booking => filterStatus === 'all' || booking.status.toLowerCase() === filterStatus) + .filter(booking => { + if (searchTerm === '') return true; + const s = searchTerm.toLowerCase(); + return ( + booking.bookingNumber?.toLowerCase().includes(s) || + booking.id.toLowerCase().includes(s) || + booking.carrierName?.toLowerCase().includes(s) || + booking.origin?.toLowerCase().includes(s) || + booking.destination?.toLowerCase().includes(s) || + String(booking.palletCount || '').includes(s) || + String(booking.weightKG || '').includes(s) || + String(booking.volumeCBM || '').includes(s) || + booking.containerType?.toLowerCase().includes(s) + ); + }); + + if (loading) { + return ( +
+
+
+

Chargement des réservations...

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

Gestion des réservations

+

+ Toutes les réservations de la plateforme +

+
+ + {/* Stats Cards */} +
+
+
Total
+
{bookings.length}
+
+
+
Virements à valider
+
+ {bookings.filter(b => b.status.toUpperCase() === 'PENDING_BANK_TRANSFER').length} +
+
+
+
En attente transporteur
+
+ {bookings.filter(b => b.status.toUpperCase() === 'PENDING').length} +
+
+
+
Acceptées
+
+ {bookings.filter(b => b.status.toUpperCase() === 'ACCEPTED').length} +
+
+
+
Rejetées
+
+ {bookings.filter(b => b.status.toUpperCase() === 'REJECTED').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 text-sm" + /> +
+
+ + +
+
+
+ + {/* Error Message */} + {error && ( +
+ {error} +
+ )} + + {/* Bookings Table */} +
+
+ + + + + + + + + + + + + + {filteredBookings.length === 0 ? ( + + + + ) : ( + filteredBookings.map(booking => ( + + {/* N° Booking */} + + + {/* Route */} + + + {/* Cargo */} + + + {/* Transporteur */} + + + {/* Statut */} + + + {/* Date */} + + + {/* Actions */} + + + )) + )} + +
+ N° Booking + + Route + + Cargo + + Transporteur + + Statut + + Date + + Actions +
+ Aucune réservation trouvée +
+ {booking.bookingNumber && ( +
{booking.bookingNumber}
+ )} +
{getShortId(booking)}
+
+
+ {booking.origin} → {booking.destination} +
+
+
+ {booking.containerType} + {booking.palletCount != null && ( + · {booking.palletCount} pal. + )} +
+
+ {booking.weightKG != null && {booking.weightKG.toLocaleString()} kg} + {booking.volumeCBM != null && {booking.volumeCBM} CBM} +
+
+ {booking.carrierName || '—'} + + + {getStatusLabel(booking.status)} + + + {new Date(booking.requestedAt || booking.createdAt || '').toLocaleDateString('fr-FR')} + + {booking.status.toUpperCase() === 'PENDING_BANK_TRANSFER' && ( + + )} +
+
+
+
+ ); +} diff --git a/apps/frontend/app/dashboard/admin/organizations/page.tsx b/apps/frontend/app/dashboard/admin/organizations/page.tsx index ddcabd7..485af68 100644 --- a/apps/frontend/app/dashboard/admin/organizations/page.tsx +++ b/apps/frontend/app/dashboard/admin/organizations/page.tsx @@ -1,7 +1,7 @@ 'use client'; import { useState, useEffect } from 'react'; -import { getAllOrganizations } from '@/lib/api/admin'; +import { getAllOrganizations, verifySiret, approveSiret, rejectSiret } from '@/lib/api/admin'; import { createOrganization, updateOrganization } from '@/lib/api/organizations'; interface Organization { @@ -10,6 +10,9 @@ interface Organization { type: string; scac?: string; siren?: string; + siret?: string; + siretVerified?: boolean; + statusBadge?: string; eori?: string; contact_phone?: string; contact_email?: string; @@ -32,6 +35,7 @@ export default function AdminOrganizationsPage() { const [selectedOrg, setSelectedOrg] = useState(null); const [showCreateModal, setShowCreateModal] = useState(false); const [showEditModal, setShowEditModal] = useState(false); + const [verifyingId, setVerifyingId] = useState(null); // Form state const [formData, setFormData] = useState<{ @@ -39,6 +43,7 @@ export default function AdminOrganizationsPage() { type: string; scac: string; siren: string; + siret: string; eori: string; contact_phone: string; contact_email: string; @@ -55,6 +60,7 @@ export default function AdminOrganizationsPage() { type: 'FREIGHT_FORWARDER', scac: '', siren: '', + siret: '', eori: '', contact_phone: '', contact_email: '', @@ -130,6 +136,7 @@ export default function AdminOrganizationsPage() { type: 'FREIGHT_FORWARDER', scac: '', siren: '', + siret: '', eori: '', contact_phone: '', contact_email: '', @@ -144,6 +151,51 @@ export default function AdminOrganizationsPage() { }); }; + const handleVerifySiret = async (orgId: string) => { + try { + setVerifyingId(orgId); + const result = await verifySiret(orgId); + if (result.verified) { + alert(`SIRET verifie avec succes !\nEntreprise: ${result.companyName || 'N/A'}\nAdresse: ${result.address || 'N/A'}`); + await fetchOrganizations(); + } else { + alert(result.message || 'SIRET invalide ou introuvable.'); + } + } catch (err: any) { + alert(err.message || 'Erreur lors de la verification du SIRET'); + } finally { + setVerifyingId(null); + } + }; + + const handleApproveSiret = async (orgId: string) => { + if (!confirm('Confirmer l\'approbation manuelle du SIRET/SIREN de cette organisation ?')) return; + try { + setVerifyingId(orgId); + const result = await approveSiret(orgId); + alert(result.message); + await fetchOrganizations(); + } catch (err: any) { + alert(err.message || 'Erreur lors de l\'approbation'); + } finally { + setVerifyingId(null); + } + }; + + const handleRejectSiret = async (orgId: string) => { + if (!confirm('Confirmer le refus du SIRET/SIREN ? L\'organisation ne pourra plus effectuer d\'achats.')) return; + try { + setVerifyingId(orgId); + const result = await rejectSiret(orgId); + alert(result.message); + await fetchOrganizations(); + } catch (err: any) { + alert(err.message || 'Erreur lors du refus'); + } finally { + setVerifyingId(null); + } + }; + const openEditModal = (org: Organization) => { setSelectedOrg(org); setFormData({ @@ -151,6 +203,7 @@ export default function AdminOrganizationsPage() { type: org.type, scac: org.scac || '', siren: org.siren || '', + siret: org.siret || '', eori: org.eori || '', contact_phone: org.contact_phone || '', contact_email: org.contact_email || '', @@ -229,6 +282,25 @@ export default function AdminOrganizationsPage() { SIREN: {org.siren} )} +
+ SIRET: + {org.siret ? ( + <> + {org.siret} + {org.siretVerified ? ( + + Verifie + + ) : ( + + Non verifie + + )} + + ) : ( + Non renseigne + )} +
{org.contact_email && (
Email: {org.contact_email} @@ -239,13 +311,45 @@ export default function AdminOrganizationsPage() {
-
- +
+
+ + {org.siret && !org.siretVerified && ( + + )} +
+ {(org.siret || org.siren) && ( +
+ {!org.siretVerified ? ( + + ) : ( + + )} +
+ )}
))} @@ -309,6 +413,18 @@ export default function AdminOrganizationsPage() { /> +
+ + setFormData({ ...formData, siret: e.target.value.replace(/\D/g, '') })} + className="mt-1 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" + placeholder="12345678901234" + /> +
+
(null); const [loading, setLoading] = useState(true); const [paying, setPaying] = useState(false); + const [declaring, setDeclaring] = useState(false); const [error, setError] = useState(null); + const [selectedMethod, setSelectedMethod] = useState(null); + const [copied, setCopied] = useState(null); useEffect(() => { async function fetchBooking() { try { const data = await getCsvBooking(bookingId); setBooking(data as any); - - // If booking is not in PENDING_PAYMENT status, redirect if (data.status !== 'PENDING_PAYMENT') { router.replace('/dashboard/bookings'); } } catch (err) { - console.error('Failed to fetch booking:', err); - setError('Impossible de charger les details du booking'); + setError('Impossible de charger les détails du booking'); } finally { setLoading(false); } } - - if (bookingId) { - fetchBooking(); - } + if (bookingId) fetchBooking(); }, [bookingId, router]); const handlePayByCard = async () => { setPaying(true); setError(null); - try { const result = await payBookingCommission(bookingId); - // Redirect to Stripe Checkout window.location.href = result.sessionUrl; } catch (err) { - console.error('Payment error:', err); - setError(err instanceof Error ? err.message : 'Erreur lors de la creation du paiement'); + setError(err instanceof Error ? err.message : 'Erreur lors de la création du paiement'); setPaying(false); } }; - const formatPrice = (price: number, currency: string) => { - return new Intl.NumberFormat('fr-FR', { - style: 'currency', - currency, - }).format(price); + const handleDeclareTransfer = async () => { + setDeclaring(true); + setError(null); + try { + await declareBankTransfer(bookingId); + router.push('/dashboard/bookings?transfer=declared'); + } catch (err) { + setError(err instanceof Error ? err.message : 'Erreur lors de la déclaration du virement'); + setDeclaring(false); + } }; + const copyToClipboard = (value: string, key: string) => { + navigator.clipboard.writeText(value); + setCopied(key); + setTimeout(() => setCopied(null), 2000); + }; + + const formatPrice = (price: number, currency: string) => + new Intl.NumberFormat('fr-FR', { style: 'currency', currency }).format(price); + if (loading) { return ( -
+
- Chargement... + Chargement...
); @@ -99,8 +126,8 @@ export default function PayCommissionPage() { if (error && !booking) { return ( -
-
+
+

{error}

+
+
+ {/* Back button */} + -
-

Paiement de la commission

-

- Finalisez votre booking en payant la commission de service -

-
-
+

Paiement de la commission

+

+ Finalisez votre booking en réglant la commission de service +

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

{error}

-
+
+ +

{error}

)} - {/* Booking Summary */} -
-

Recapitulatif du booking

+
+ {/* LEFT — Payment method selector */} +
+

+ Choisir le mode de paiement +

-
- {booking.bookingNumber && ( -
- Numero : - {booking.bookingNumber} + {/* Card option */} + + + {/* Transfer option */} + + + {/* Card action */} + {selectedMethod === 'card' && ( +
+

+ Vous serez redirigé vers Stripe pour finaliser votre paiement en toute sécurité. +

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

Commission de service

- -
-
-
+ {/* Transfer action */} + {selectedMethod === 'transfer' && ( +

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

-

- {formatPrice(booking.priceEUR, 'EUR')} x {commissionRate}% + Effectuez le virement avec les coordonnées ci-dessous, puis cliquez sur + “J'ai effectué le virement”.

+ + {/* Bank details */} +
+ {[ + { label: 'Bénéficiaire', value: BANK_DETAILS.beneficiary, key: 'beneficiary' }, + { label: 'IBAN', value: BANK_DETAILS.iban, key: 'iban', mono: true }, + { label: 'BIC / SWIFT', value: BANK_DETAILS.bic, key: 'bic', mono: true }, + { + label: 'Montant', + value: formatPrice(commissionAmount, 'EUR'), + key: 'amount', + bold: true, + }, + { label: 'Référence', value: reference, key: 'ref', mono: true }, + ].map(({ label, value, key, mono, bold }) => ( +
+ {label} +
+ + {value} + + {key !== 'amount' && ( + + )} +
+
+ ))} +
+ +
+ + + Mentionnez impérativement la référence {reference} dans le + libellé du virement. + +
+ +
-

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

-
+ )} + + {/* Placeholder when no method selected */} + {selectedMethod === null && ( +
+ Sélectionnez un mode de paiement ci-dessus +
+ )}
-
-
- -

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

-
-
-
+ {/* RIGHT — Booking summary */} +
+

+ Récapitulatif +

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

Payer par virement bancaire

-
- -
-
- Beneficiaire : - XPEDITIS SAS +
+ Transporteur + + {booking.carrierName} +
-
- IBAN : - FR76 XXXX XXXX XXXX XXXX XXXX XXX +
+ Trajet + + {booking.origin} → {booking.destination} +
-
- BIC : - XXXXXXXX +
+ Volume / Poids + + {booking.volumeCBM} CBM · {booking.weightKG} kg +
-
- Montant : - {formatPrice(commissionAmount, 'EUR')} +
+ Transit + {booking.transitDays} jours
-
- Reference : - {booking.bookingNumber || booking.id.slice(0, 8)} +
+ Prix transport + + {formatPrice(booking.priceEUR, 'EUR')} +
-

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

+ {/* Commission box */} +
+

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

+

{formatPrice(commissionAmount, 'EUR')}

+

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

+
+ +
+ +

+ Après validation du paiement, votre demande est envoyée au transporteur ( + {booking.carrierEmail}). Vous serez notifié de sa réponse. +

+
diff --git a/apps/frontend/app/dashboard/bookings/page.tsx b/apps/frontend/app/dashboard/bookings/page.tsx index 89d04bf..2f31216 100644 --- a/apps/frontend/app/dashboard/bookings/page.tsx +++ b/apps/frontend/app/dashboard/bookings/page.tsx @@ -6,22 +6,31 @@ 'use client'; -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { useQuery } from '@tanstack/react-query'; import { listBookings, listCsvBookings } from '@/lib/api'; import Link from 'next/link'; -import { Plus } from 'lucide-react'; +import { Plus, Clock } from 'lucide-react'; import ExportButton from '@/components/ExportButton'; +import { useSearchParams } from 'next/navigation'; type SearchType = 'pallets' | 'weight' | 'route' | 'status' | 'date' | 'quote'; export default function BookingsListPage() { + const searchParams = useSearchParams(); const [searchTerm, setSearchTerm] = useState(''); const [searchType, setSearchType] = useState('route'); const [statusFilter, setStatusFilter] = useState(''); const [page, setPage] = useState(1); + const [showTransferBanner, setShowTransferBanner] = useState(false); const ITEMS_PER_PAGE = 20; + useEffect(() => { + if (searchParams.get('transfer') === 'declared') { + setShowTransferBanner(true); + } + }, [searchParams]); + // Fetch CSV bookings (fetch all for client-side filtering and pagination) const { data: csvData, isLoading, error: csvError } = useQuery({ queryKey: ['csv-bookings'], @@ -142,6 +151,21 @@ export default function BookingsListPage() { return (
+ {/* Bank transfer declared banner */} + {showTransferBanner && ( +
+
+ +
+

Virement déclaré

+

+ Votre virement a été enregistré. Un administrateur va vérifier la réception et activer votre booking. Vous serez notifié dès la validation. +

+
+
+ +
+ )} {/* Header */}
diff --git a/apps/frontend/app/page.tsx b/apps/frontend/app/page.tsx index be04465..d5f2af3 100644 --- a/apps/frontend/app/page.tsx +++ b/apps/frontend/app/page.tsx @@ -85,6 +85,8 @@ export default function LandingPage() { const isCtaInView = useInView(ctaRef, { once: true }); const isHowInView = useInView(howRef, { once: true, amount: 0.2 }); + const [billingYearly, setBillingYearly] = useState(false); + const { scrollYProgress } = useScroll(); const backgroundY = useTransform(scrollYProgress, [0, 1], ['0%', '50%']); @@ -185,58 +187,120 @@ export default function LandingPage() { const pricingPlans = [ { - name: 'Starter', - price: 'Gratuit', - period: '', - description: 'Idéal pour découvrir la plateforme', + key: 'bronze', + name: 'Bronze', + badge: null, + monthlyPrice: 0, + yearlyPrice: 0, + yearlyMonthly: 0, + description: 'Pour découvrir la plateforme', + users: '1 utilisateur', + shipments: '12 expéditions / an', + commission: '5%', + support: 'Aucun support', features: [ - { text: 'Jusqu\'à 5 bookings/mois', included: true }, - { text: 'Track & Trace illimité', included: true }, - { text: 'Wiki maritime complet', included: true }, - { text: 'Dashboard basique', included: true }, - { text: 'Support par email', included: true }, - { text: 'Gestion des documents', included: false }, - { text: 'Notifications temps réel', included: false }, + { text: 'Réservations maritimes LCL', included: true }, + { text: 'Track & Trace conteneurs', included: true }, + { text: 'Tableau de bord', included: false }, + { text: 'Wiki maritime', included: false }, + { text: 'Gestion des utilisateurs', included: false }, + { text: 'Export CSV', included: false }, { text: 'Accès API', included: false }, + { text: 'KAM dédié', included: false }, ], cta: 'Commencer gratuitement', + ctaLink: '/register', highlighted: false, + accentColor: 'from-amber-600 to-yellow-500', + textAccent: 'text-amber-700', + badgeBg: 'bg-amber-100 text-amber-800', }, { - name: 'Professional', - price: '99€', - period: '/mois', + key: 'silver', + name: 'Silver', + badge: 'Populaire', + monthlyPrice: 249, + yearlyPrice: 2739, + yearlyMonthly: 228, description: 'Pour les transitaires en croissance', + users: 'Jusqu\'à 5 utilisateurs', + shipments: 'Expéditions illimitées', + commission: '3%', + support: 'Support par email', features: [ - { text: 'Bookings illimités', included: true }, - { text: 'Track & Trace illimité', included: true }, + { text: 'Réservations maritimes LCL', included: true }, + { text: 'Track & Trace conteneurs', included: true }, + { text: 'Tableau de bord avancé', included: true }, { text: 'Wiki maritime complet', included: true }, - { text: 'Dashboard avancé + KPIs', included: true }, - { text: 'Support prioritaire', included: true }, - { text: 'Gestion des documents', included: true }, - { text: 'Notifications temps réel', included: true }, + { text: 'Gestion des utilisateurs', included: true }, + { text: 'Export CSV', included: true }, { text: 'Accès API', included: false }, + { text: 'KAM dédié', included: false }, ], cta: 'Essai gratuit 14 jours', + ctaLink: '/register', highlighted: true, + accentColor: 'from-slate-400 to-slate-500', + textAccent: 'text-slate-600', + badgeBg: 'bg-slate-100 text-slate-700', }, { - name: 'Enterprise', - price: 'Sur mesure', - period: '', - description: 'Pour les grandes entreprises', + key: 'gold', + name: 'Gold', + badge: null, + monthlyPrice: 899, + yearlyPrice: 9889, + yearlyMonthly: 824, + description: 'Pour les équipes exigeantes', + users: 'Jusqu\'à 20 utilisateurs', + shipments: 'Expéditions illimitées', + commission: '2%', + support: 'Assistance commerciale directe', features: [ - { text: 'Tout Professionnel +', included: true }, + { text: 'Réservations maritimes LCL', included: true }, + { text: 'Track & Trace conteneurs', included: true }, + { text: 'Tableau de bord avancé', included: true }, + { text: 'Wiki maritime complet', included: true }, + { text: 'Gestion des utilisateurs', included: true }, + { text: 'Export CSV', included: true }, { text: 'Accès API complet', included: true }, - { text: 'Intégrations personnalisées', included: true }, - { text: 'Responsable de compte dédié', included: true }, - { text: 'SLA garanti 99.9%', included: true }, - { text: 'Formation sur site', included: true }, - { text: 'Multi-organisations', included: true }, - { text: 'Audit & conformité', included: true }, + { text: 'KAM dédié', included: false }, ], - cta: 'Contactez-nous', + cta: 'Essai gratuit 14 jours', + ctaLink: '/register', highlighted: false, + accentColor: 'from-yellow-400 to-amber-400', + textAccent: 'text-amber-600', + badgeBg: 'bg-yellow-50 text-amber-700', + }, + { + key: 'platinium', + name: 'Platinium', + badge: 'Sur mesure', + monthlyPrice: null, + yearlyPrice: null, + yearlyMonthly: null, + description: 'Pour les grandes entreprises', + users: 'Utilisateurs illimités', + shipments: 'Expéditions illimitées', + commission: '1%', + support: 'Key Account Manager dédié', + features: [ + { text: 'Réservations maritimes LCL', included: true }, + { text: 'Track & Trace conteneurs', included: true }, + { text: 'Tableau de bord avancé', included: true }, + { text: 'Wiki maritime complet', included: true }, + { text: 'Gestion des utilisateurs', included: true }, + { text: 'Export CSV', included: true }, + { text: 'Accès API complet', included: true }, + { text: 'KAM dédié + Interface personnalisée', included: true }, + ], + cta: 'Nous contacter', + ctaLink: '/contact', + highlighted: false, + accentColor: 'from-brand-navy to-brand-turquoise', + textAccent: 'text-brand-turquoise', + badgeBg: 'bg-brand-navy/10 text-brand-navy', }, ]; @@ -655,76 +719,198 @@ export default function LandingPage() {
+ {/* Header */} + + Tarifs +

- Tarifs simples et transparents + Des plans adaptés à votre activité

- Choisissez le plan adapté à vos besoins. Évoluez à tout moment. + De l'accès découverte au partenariat sur mesure — évoluez à tout moment.

+ {/* Billing Toggle */} + + + Mensuel + + + + Annuel + + {billingYearly && ( + + 1 mois offert + + )} + + + {/* Plans Grid */} {pricingPlans.map((plan, index) => ( - {plan.highlighted && ( -
- - Populaire + {/* Top gradient bar */} +
+ + {/* Popular badge */} + {plan.badge && plan.key === 'silver' && ( +
+ + {plan.badge}
)} -
-

{plan.name}

-

{plan.description}

-
- {plan.price} - {plan.period} + {plan.badge && plan.key === 'platinium' && ( +
+ + {plan.badge} +
-
    - {plan.features.map((feature, featureIndex) => ( -
  • - {feature.included ? ( - + )} + +
    + {/* Plan name */} +
    +
    +
    + {plan.name} +
    +

    + {plan.description} +

    +
    + + {/* Price */} +
    + {plan.monthlyPrice === null ? ( +
    + + Sur mesure + +

    + Tarification personnalisée +

    +
    + ) : plan.monthlyPrice === 0 ? ( +
    + + Gratuit + +

    + Pour toujours +

    +
    + ) : ( +
    +
    + + {billingYearly ? plan.yearlyMonthly : plan.monthlyPrice}€ + + + /mois + +
    + {billingYearly ? ( +

    + Facturé {plan.yearlyPrice?.toLocaleString('fr-FR')}€/an +

    ) : ( - +

    + Économisez 1 mois avec l'annuel +

    )} - +
    + )} +
    + + {/* Key stats */} +
    +
    + + {plan.users} +
    +
    + + {plan.shipments} +
    +
    + + + Commission {plan.commission} + +
    +
    + + {/* Features */} +
      + {plan.features.map((feature, featureIndex) => ( +
    • + {feature.included ? ( + + ) : ( + + )} + {feature.text}
    • ))}
    + + {/* CTA */} {plan.cta} @@ -734,17 +920,21 @@ export default function LandingPage() { ))} + {/* Bottom note */} -

    - Tous les plans incluent un essai gratuit de 14 jours. Aucune carte bancaire requise. +

    + Plans Silver et Gold : essai gratuit 14 jours inclus · Aucune carte bancaire requise

    -

    - Des questions ? Contactez notre équipe commerciale +

    + Des questions ?{' '} + + Contactez notre équipe commerciale +

    diff --git a/apps/frontend/app/register/page.tsx b/apps/frontend/app/register/page.tsx index 3e3da18..bed9e40 100644 --- a/apps/frontend/app/register/page.tsx +++ b/apps/frontend/app/register/page.tsx @@ -27,6 +27,7 @@ export default function RegisterPage() { // Organization fields const [organizationName, setOrganizationName] = useState(''); const [organizationType, setOrganizationType] = useState('FREIGHT_FORWARDER'); + const [siren, setSiren] = useState(''); const [street, setStreet] = useState(''); const [city, setCity] = useState(''); const [state, setState] = useState(''); @@ -87,6 +88,11 @@ export default function RegisterPage() { return; } + if (!siren.trim() || !/^[0-9]{9}$/.test(siren)) { + setError('Le numero SIREN est requis (9 chiffres)'); + return; + } + if (!street.trim() || !city.trim() || !postalCode.trim() || !country.trim()) { setError('Tous les champs d\'adresse sont requis'); return; @@ -108,6 +114,7 @@ export default function RegisterPage() { organization: { name: organizationName, type: organizationType, + siren, street, city, state: state || undefined, @@ -309,6 +316,25 @@ export default function RegisterPage() {
    + {/* SIREN */} +
    + + setSiren(e.target.value.replace(/\D/g, ''))} + className="input w-full" + placeholder="123456789" + maxLength={9} + disabled={isLoading} + /> +

    9 chiffres, obligatoire pour toute organisation

    +
    + {/* Street Address */}
  • -
  • - - Carrières - -
  • Blog
  • -
  • - - Presse - -
@@ -117,11 +107,6 @@ export function LandingFooter() { Politique de cookies -
  • - - Sécurité - -
  • Conformité RGPD diff --git a/apps/frontend/src/components/layout/LandingHeader.tsx b/apps/frontend/src/components/layout/LandingHeader.tsx index 174db90..f11307e 100644 --- a/apps/frontend/src/components/layout/LandingHeader.tsx +++ b/apps/frontend/src/components/layout/LandingHeader.tsx @@ -6,8 +6,6 @@ import Image from 'next/image'; import { motion, AnimatePresence } from 'framer-motion'; import { ChevronDown, - Briefcase, - Newspaper, Info, BookOpen, LayoutDashboard, @@ -26,14 +24,12 @@ export function LandingHeader({ transparentOnTop = false, activePage }: LandingH const companyMenuItems = [ { href: '/about', label: 'À propos', icon: Info, description: 'Notre histoire et mission' }, - { href: '/careers', label: 'Carrières', icon: Briefcase, description: 'Rejoignez-nous' }, { href: '/blog', label: 'Blog', icon: BookOpen, description: 'Actualités et insights' }, - { href: '/press', label: 'Presse', icon: Newspaper, description: 'Espace presse' }, ]; // "Entreprise" dropdown is active only for its own sub-pages (not contact) const isCompanyMenuActive = - activePage !== undefined && ['about', 'careers', 'blog', 'press'].includes(activePage); + activePage !== undefined && ['about', 'blog'].includes(activePage); const getUserInitials = () => { if (!user) return ''; diff --git a/apps/frontend/src/components/organization/LicensesTab.tsx b/apps/frontend/src/components/organization/LicensesTab.tsx index 694524c..5110397 100644 --- a/apps/frontend/src/components/organization/LicensesTab.tsx +++ b/apps/frontend/src/components/organization/LicensesTab.tsx @@ -65,7 +65,6 @@ export default function LicensesTab() {

    {subscription?.usedLicenses || 0}

    -

    Hors ADMIN (illimité)

  • Licences disponibles

    diff --git a/apps/frontend/src/lib/api/admin.ts b/apps/frontend/src/lib/api/admin.ts index a06d3fb..78ce939 100644 --- a/apps/frontend/src/lib/api/admin.ts +++ b/apps/frontend/src/lib/api/admin.ts @@ -80,6 +80,39 @@ export async function getAdminOrganization(id: string): Promise(`/api/v1/admin/organizations/${id}`); } +/** + * Verify SIRET for an organization via Pappers API (admin only) + * POST /api/v1/admin/organizations/:id/verify-siret + * Requires: ADMIN role + */ +export async function verifySiret( + organizationId: string +): Promise<{ verified: boolean; companyName?: string; address?: string; message: string }> { + return post(`/api/v1/admin/organizations/${organizationId}/verify-siret`, {}); +} + +/** + * Manually approve SIRET/SIREN for an organization (admin only) + * POST /api/v1/admin/organizations/:id/approve-siret + * Requires: ADMIN role + */ +export async function approveSiret( + organizationId: string +): Promise<{ approved: boolean; message: string; organizationName: string }> { + return post(`/api/v1/admin/organizations/${organizationId}/approve-siret`, {}); +} + +/** + * Reject SIRET/SIREN for an organization (admin only) + * POST /api/v1/admin/organizations/:id/reject-siret + * Requires: ADMIN role + */ +export async function rejectSiret( + organizationId: string +): Promise<{ rejected: boolean; message: string; organizationName: string }> { + return post(`/api/v1/admin/organizations/${organizationId}/reject-siret`, {}); +} + // ==================== BOOKINGS ==================== /** @@ -101,6 +134,16 @@ export async function getAdminBooking(id: string): Promise { return get(`/api/v1/admin/bookings/${id}`); } +/** + * Validate bank transfer for a booking (admin only) + * POST /api/v1/admin/bookings/:id/validate-transfer + * Confirms receipt of wire transfer and activates the booking + * Requires: ADMIN role + */ +export async function validateBankTransfer(bookingId: string): Promise { + return post(`/api/v1/admin/bookings/${bookingId}/validate-transfer`, {}); +} + // ==================== DOCUMENTS ==================== /** diff --git a/apps/frontend/src/lib/api/bookings.ts b/apps/frontend/src/lib/api/bookings.ts index e96d65d..e8138d7 100644 --- a/apps/frontend/src/lib/api/bookings.ts +++ b/apps/frontend/src/lib/api/bookings.ts @@ -318,3 +318,11 @@ export async function confirmBookingPayment( sessionId, }); } + +/** + * Declare bank transfer — user confirms they have sent the wire transfer + * POST /api/v1/csv-bookings/:id/declare-transfer + */ +export async function declareBankTransfer(bookingId: string): Promise { + return post(`/api/v1/csv-bookings/${bookingId}/declare-transfer`, {}); +} diff --git a/apps/frontend/src/types/api.ts b/apps/frontend/src/types/api.ts index 8a3245c..12543d1 100644 --- a/apps/frontend/src/types/api.ts +++ b/apps/frontend/src/types/api.ts @@ -11,6 +11,7 @@ export interface RegisterOrganizationData { name: string; type: OrganizationType; + siren: string; street: string; city: string; state?: string; @@ -120,6 +121,7 @@ export interface CreateOrganizationRequest { export interface UpdateOrganizationRequest { name?: string; siren?: string; + siret?: string; eori?: string; contact_phone?: string; contact_email?: string;