Compare commits

..

No commits in common. "420e52311cb7242f3a814ca3c4bfa5d162b463a5" and "1c6edb9d41072bea15e61b2ed4bd6354375ab2e8" have entirely different histories.

92 changed files with 1350 additions and 4720 deletions

View File

@ -84,8 +84,6 @@ Docker-compose defaults (no `.env` changes needed for local dev):
- **Redis**: password `xpeditis_redis_password`, port 6379 - **Redis**: password `xpeditis_redis_password`, port 6379
- **MinIO** (S3-compatible storage): `minioadmin:minioadmin`, API port 9000, console port 9001 - **MinIO** (S3-compatible storage): `minioadmin:minioadmin`, API port 9000, console port 9001
Frontend env var: `NEXT_PUBLIC_API_URL` (defaults to `http://localhost:4000`) — configured in `next.config.js`.
## Architecture ## Architecture
### Hexagonal Architecture (Backend) ### Hexagonal Architecture (Backend)
@ -188,14 +186,7 @@ Immutable, self-validating via static `create()`. E.g. `Money` supports USD, EUR
- Separate mapper classes (`infrastructure/persistence/typeorm/mappers/`) with static `toOrm()`, `toDomain()`, `toDomainMany()` methods - Separate mapper classes (`infrastructure/persistence/typeorm/mappers/`) with static `toOrm()`, `toDomain()`, `toDomainMany()` methods
### Frontend API Client ### Frontend API Client
Custom Fetch wrapper in `src/lib/api/client.ts` — exports `get()`, `post()`, `patch()`, `del()`, `upload()`, `download()`. Auto-refreshes JWT on 401. Tokens stored in localStorage **and synced to cookies** (`accessToken` cookie) so Next.js middleware can read them server-side. Per-module files (auth.ts, bookings.ts, rates.ts, etc.) import from client. Custom Fetch wrapper in `src/lib/api/client.ts` — exports `get()`, `post()`, `patch()`, `del()`, `upload()`, `download()`. Auto-refreshes JWT on 401. Tokens stored in localStorage. Per-module files (auth.ts, bookings.ts, rates.ts, etc.) import from client.
### Route Protection (Middleware)
`apps/frontend/middleware.ts` checks the `accessToken` cookie to protect routes. Public paths are defined in two lists:
- `exactPublicPaths`: exact matches (e.g. `/`)
- `prefixPublicPaths`: prefix matches including sub-paths (e.g. `/login`, `/carrier`, `/about`, etc.)
All other routes redirect to `/login?redirect=<pathname>` when the cookie is absent.
### Application Decorators ### Application Decorators
- `@Public()` — skip JWT auth - `@Public()` — skip JWT auth

View File

@ -93,9 +93,9 @@ STRIPE_SECRET_KEY=sk_test_your_stripe_secret_key
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret
# Stripe Price IDs (create these in Stripe Dashboard) # Stripe Price IDs (create these in Stripe Dashboard)
STRIPE_SILVER_MONTHLY_PRICE_ID=price_silver_monthly STRIPE_STARTER_MONTHLY_PRICE_ID=price_starter_monthly
STRIPE_SILVER_YEARLY_PRICE_ID=price_silver_yearly STRIPE_STARTER_YEARLY_PRICE_ID=price_starter_yearly
STRIPE_GOLD_MONTHLY_PRICE_ID=price_gold_monthly STRIPE_PRO_MONTHLY_PRICE_ID=price_pro_monthly
STRIPE_GOLD_YEARLY_PRICE_ID=price_gold_yearly STRIPE_PRO_YEARLY_PRICE_ID=price_pro_yearly
STRIPE_PLATINIUM_MONTHLY_PRICE_ID=price_platinium_monthly STRIPE_ENTERPRISE_MONTHLY_PRICE_ID=price_enterprise_monthly
STRIPE_PLATINIUM_YEARLY_PRICE_ID=price_platinium_yearly STRIPE_ENTERPRISE_YEARLY_PRICE_ID=price_enterprise_yearly

View File

@ -60,12 +60,12 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard';
// Stripe Configuration (optional for development) // Stripe Configuration (optional for development)
STRIPE_SECRET_KEY: Joi.string().optional(), STRIPE_SECRET_KEY: Joi.string().optional(),
STRIPE_WEBHOOK_SECRET: Joi.string().optional(), STRIPE_WEBHOOK_SECRET: Joi.string().optional(),
STRIPE_SILVER_MONTHLY_PRICE_ID: Joi.string().optional(), STRIPE_STARTER_MONTHLY_PRICE_ID: Joi.string().optional(),
STRIPE_SILVER_YEARLY_PRICE_ID: Joi.string().optional(), STRIPE_STARTER_YEARLY_PRICE_ID: Joi.string().optional(),
STRIPE_GOLD_MONTHLY_PRICE_ID: Joi.string().optional(), STRIPE_PRO_MONTHLY_PRICE_ID: Joi.string().optional(),
STRIPE_GOLD_YEARLY_PRICE_ID: Joi.string().optional(), STRIPE_PRO_YEARLY_PRICE_ID: Joi.string().optional(),
STRIPE_PLATINIUM_MONTHLY_PRICE_ID: Joi.string().optional(), STRIPE_ENTERPRISE_MONTHLY_PRICE_ID: Joi.string().optional(),
STRIPE_PLATINIUM_YEARLY_PRICE_ID: Joi.string().optional(), STRIPE_ENTERPRISE_YEARLY_PRICE_ID: Joi.string().optional(),
}), }),
}), }),

View File

@ -1,6 +1,5 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule } from '@nestjs/config';
// Controller // Controller
import { AdminController } from '../controllers/admin.controller'; import { AdminController } from '../controllers/admin.controller';
@ -19,13 +18,6 @@ import { TypeOrmCsvBookingRepository } from '@infrastructure/persistence/typeorm
import { USER_REPOSITORY } from '@domain/ports/out/user.repository'; import { USER_REPOSITORY } from '@domain/ports/out/user.repository';
import { ORGANIZATION_REPOSITORY } from '@domain/ports/out/organization.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 * Admin Module
* *
@ -33,11 +25,7 @@ import { CsvBookingsModule } from '../csv-bookings.module';
* All endpoints require ADMIN role. * All endpoints require ADMIN role.
*/ */
@Module({ @Module({
imports: [ imports: [TypeOrmModule.forFeature([UserOrmEntity, OrganizationOrmEntity, CsvBookingOrmEntity])],
TypeOrmModule.forFeature([UserOrmEntity, OrganizationOrmEntity, CsvBookingOrmEntity]),
ConfigModule,
CsvBookingsModule,
],
controllers: [AdminController], controllers: [AdminController],
providers: [ providers: [
{ {
@ -49,10 +37,6 @@ import { CsvBookingsModule } from '../csv-bookings.module';
useClass: TypeOrmOrganizationRepository, useClass: TypeOrmOrganizationRepository,
}, },
TypeOrmCsvBookingRepository, TypeOrmCsvBookingRepository,
{
provide: SIRET_VERIFICATION_PORT,
useClass: PappersSiretAdapter,
},
], ],
}) })
export class AdminModule {} export class AdminModule {}

View File

@ -25,8 +25,6 @@ export interface JwtPayload {
email: string; email: string;
role: string; role: string;
organizationId: string; organizationId: string;
plan?: string; // subscription plan (BRONZE, SILVER, GOLD, PLATINIUM)
planFeatures?: string[]; // plan feature flags
type: 'access' | 'refresh'; type: 'access' | 'refresh';
} }
@ -41,7 +39,7 @@ export class AuthService {
private readonly organizationRepository: OrganizationRepository, private readonly organizationRepository: OrganizationRepository,
private readonly jwtService: JwtService, private readonly jwtService: JwtService,
private readonly configService: ConfigService, private readonly configService: ConfigService,
private readonly subscriptionService: SubscriptionService private readonly subscriptionService: SubscriptionService,
) {} ) {}
/** /**
@ -222,40 +220,11 @@ export class AuthService {
* Generate access and refresh tokens * Generate access and refresh tokens
*/ */
private async generateTokens(user: User): Promise<{ accessToken: string; refreshToken: string }> { private async generateTokens(user: User): Promise<{ accessToken: string; refreshToken: string }> {
// ADMIN users always get PLATINIUM plan with no expiration
let plan = 'BRONZE';
let planFeatures: string[] = [];
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 = { const accessPayload: JwtPayload = {
sub: user.id, sub: user.id,
email: user.email, email: user.email,
role: user.role, role: user.role,
organizationId: user.organizationId, organizationId: user.organizationId,
plan,
planFeatures,
type: 'access', type: 'access',
}; };
@ -264,8 +233,6 @@ export class AuthService {
email: user.email, email: user.email,
role: user.role, role: user.role,
organizationId: user.organizationId, organizationId: user.organizationId,
plan,
planFeatures,
type: 'refresh', type: 'refresh',
}; };
@ -335,7 +302,6 @@ export class AuthService {
name: organizationData.name, name: organizationData.name,
type: organizationData.type, type: organizationData.type,
scac: organizationData.scac, scac: organizationData.scac,
siren: organizationData.siren,
address: { address: {
street: organizationData.street, street: organizationData.street,
city: organizationData.city, city: organizationData.city,

View File

@ -6,18 +6,15 @@ import { BookingsController } from '../controllers/bookings.controller';
import { BOOKING_REPOSITORY } from '@domain/ports/out/booking.repository'; import { BOOKING_REPOSITORY } from '@domain/ports/out/booking.repository';
import { RATE_QUOTE_REPOSITORY } from '@domain/ports/out/rate-quote.repository'; import { RATE_QUOTE_REPOSITORY } from '@domain/ports/out/rate-quote.repository';
import { USER_REPOSITORY } from '@domain/ports/out/user.repository'; import { USER_REPOSITORY } from '@domain/ports/out/user.repository';
import { SHIPMENT_COUNTER_PORT } from '@domain/ports/out/shipment-counter.port';
import { TypeOrmBookingRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-booking.repository'; import { TypeOrmBookingRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-booking.repository';
import { TypeOrmRateQuoteRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-rate-quote.repository'; import { TypeOrmRateQuoteRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-rate-quote.repository';
import { TypeOrmUserRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-user.repository'; import { TypeOrmUserRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-user.repository';
import { TypeOrmShipmentCounterRepository } from '../../infrastructure/persistence/typeorm/repositories/shipment-counter.repository';
// Import ORM entities // Import ORM entities
import { BookingOrmEntity } from '../../infrastructure/persistence/typeorm/entities/booking.orm-entity'; import { BookingOrmEntity } from '../../infrastructure/persistence/typeorm/entities/booking.orm-entity';
import { ContainerOrmEntity } from '../../infrastructure/persistence/typeorm/entities/container.orm-entity'; import { ContainerOrmEntity } from '../../infrastructure/persistence/typeorm/entities/container.orm-entity';
import { RateQuoteOrmEntity } from '../../infrastructure/persistence/typeorm/entities/rate-quote.orm-entity'; import { RateQuoteOrmEntity } from '../../infrastructure/persistence/typeorm/entities/rate-quote.orm-entity';
import { UserOrmEntity } from '../../infrastructure/persistence/typeorm/entities/user.orm-entity'; import { UserOrmEntity } from '../../infrastructure/persistence/typeorm/entities/user.orm-entity';
import { CsvBookingOrmEntity } from '../../infrastructure/persistence/typeorm/entities/csv-booking.orm-entity';
// Import services and domain // Import services and domain
import { BookingService } from '@domain/services/booking.service'; import { BookingService } from '@domain/services/booking.service';
@ -32,7 +29,6 @@ import { StorageModule } from '../../infrastructure/storage/storage.module';
import { AuditModule } from '../audit/audit.module'; import { AuditModule } from '../audit/audit.module';
import { NotificationsModule } from '../notifications/notifications.module'; import { NotificationsModule } from '../notifications/notifications.module';
import { WebhooksModule } from '../webhooks/webhooks.module'; import { WebhooksModule } from '../webhooks/webhooks.module';
import { SubscriptionsModule } from '../subscriptions/subscriptions.module';
/** /**
* Bookings Module * Bookings Module
@ -51,7 +47,6 @@ import { SubscriptionsModule } from '../subscriptions/subscriptions.module';
ContainerOrmEntity, ContainerOrmEntity,
RateQuoteOrmEntity, RateQuoteOrmEntity,
UserOrmEntity, UserOrmEntity,
CsvBookingOrmEntity,
]), ]),
EmailModule, EmailModule,
PdfModule, PdfModule,
@ -59,7 +54,6 @@ import { SubscriptionsModule } from '../subscriptions/subscriptions.module';
AuditModule, AuditModule,
NotificationsModule, NotificationsModule,
WebhooksModule, WebhooksModule,
SubscriptionsModule,
], ],
controllers: [BookingsController], controllers: [BookingsController],
providers: [ providers: [
@ -79,10 +73,6 @@ import { SubscriptionsModule } from '../subscriptions/subscriptions.module';
provide: USER_REPOSITORY, provide: USER_REPOSITORY,
useClass: TypeOrmUserRepository, useClass: TypeOrmUserRepository,
}, },
{
provide: SHIPMENT_COUNTER_PORT,
useClass: TypeOrmShipmentCounterRepository,
},
], ],
exports: [BOOKING_REPOSITORY], exports: [BOOKING_REPOSITORY],
}) })

View File

@ -1,7 +1,6 @@
import { import {
Controller, Controller,
Get, Get,
Post,
Patch, Patch,
Delete, Delete,
Param, Param,
@ -45,13 +44,6 @@ import { OrganizationResponseDto, OrganizationListResponseDto } from '../dto/org
// CSV Booking imports // CSV Booking imports
import { TypeOrmCsvBookingRepository } from '@infrastructure/persistence/typeorm/repositories/csv-booking.repository'; 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 * Admin Controller
@ -73,10 +65,7 @@ export class AdminController {
@Inject(USER_REPOSITORY) private readonly userRepository: UserRepository, @Inject(USER_REPOSITORY) private readonly userRepository: UserRepository,
@Inject(ORGANIZATION_REPOSITORY) @Inject(ORGANIZATION_REPOSITORY)
private readonly organizationRepository: OrganizationRepository, 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 ==================== // ==================== USERS ENDPOINTS ====================
@ -340,163 +329,6 @@ export class AdminController {
return OrganizationMapper.toDto(organization); 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 ==================== // ==================== CSV BOOKINGS ENDPOINTS ====================
/** /**
@ -608,28 +440,6 @@ export class AdminController {
return this.csvBookingToDto(updatedBooking); 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) * Delete csv booking (admin only)
*/ */
@ -673,7 +483,6 @@ export class AdminController {
return { return {
id: booking.id, id: booking.id,
bookingNumber: booking.bookingNumber || null,
userId: booking.userId, userId: booking.userId,
organizationId: booking.organizationId, organizationId: booking.organizationId,
carrierName: booking.carrierName, carrierName: booking.carrierName,

View File

@ -53,12 +53,6 @@ import { NotificationService } from '../services/notification.service';
import { NotificationsGateway } from '../gateways/notifications.gateway'; import { NotificationsGateway } from '../gateways/notifications.gateway';
import { WebhookService } from '../services/webhook.service'; import { WebhookService } from '../services/webhook.service';
import { WebhookEvent } from '@domain/entities/webhook.entity'; import { WebhookEvent } from '@domain/entities/webhook.entity';
import {
ShipmentCounterPort,
SHIPMENT_COUNTER_PORT,
} from '@domain/ports/out/shipment-counter.port';
import { SubscriptionService } from '../services/subscription.service';
import { ShipmentLimitExceededException } from '@domain/exceptions/shipment-limit-exceeded.exception';
@ApiTags('Bookings') @ApiTags('Bookings')
@Controller('bookings') @Controller('bookings')
@ -76,9 +70,7 @@ export class BookingsController {
private readonly auditService: AuditService, private readonly auditService: AuditService,
private readonly notificationService: NotificationService, private readonly notificationService: NotificationService,
private readonly notificationsGateway: NotificationsGateway, private readonly notificationsGateway: NotificationsGateway,
private readonly webhookService: WebhookService, private readonly webhookService: WebhookService
@Inject(SHIPMENT_COUNTER_PORT) private readonly shipmentCounter: ShipmentCounterPort,
private readonly subscriptionService: SubscriptionService
) {} ) {}
@Post() @Post()
@ -113,22 +105,6 @@ export class BookingsController {
): Promise<BookingResponseDto> { ): Promise<BookingResponseDto> {
this.logger.log(`[User: ${user.email}] Creating booking for rate quote: ${dto.rateQuoteId}`); this.logger.log(`[User: ${user.email}] Creating booking for rate quote: ${dto.rateQuoteId}`);
// Check shipment limit for Bronze plan
const subscription = await this.subscriptionService.getOrCreateSubscription(
user.organizationId
);
const maxShipments = subscription.plan.maxShipmentsPerYear;
if (maxShipments !== -1) {
const currentYear = new Date().getFullYear();
const count = await this.shipmentCounter.countShipmentsForOrganizationInYear(
user.organizationId,
currentYear
);
if (count >= maxShipments) {
throw new ShipmentLimitExceededException(user.organizationId, count, maxShipments);
}
}
try { try {
// Convert DTO to domain input, using authenticated user's data // Convert DTO to domain input, using authenticated user's data
const input = { const input = {
@ -480,16 +456,9 @@ export class BookingsController {
// Filter out bookings or rate quotes that are null // Filter out bookings or rate quotes that are null
const bookingsWithQuotes = bookingsWithQuotesRaw.filter( const bookingsWithQuotes = bookingsWithQuotesRaw.filter(
( (item): item is { booking: NonNullable<typeof item.booking>; rateQuote: NonNullable<typeof item.rateQuote> } =>
item item.booking !== null && item.booking !== undefined &&
): item is { item.rateQuote !== null && item.rateQuote !== undefined
booking: NonNullable<typeof item.booking>;
rateQuote: NonNullable<typeof item.rateQuote>;
} =>
item.booking !== null &&
item.booking !== undefined &&
item.rateQuote !== null &&
item.rateQuote !== undefined
); );
// Convert to DTOs // Convert to DTOs

View File

@ -12,12 +12,9 @@ import {
UploadedFiles, UploadedFiles,
Request, Request,
BadRequestException, BadRequestException,
ForbiddenException,
ParseIntPipe, ParseIntPipe,
DefaultValuePipe, DefaultValuePipe,
Inject,
} from '@nestjs/common'; } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { FilesInterceptor } from '@nestjs/platform-express'; import { FilesInterceptor } from '@nestjs/platform-express';
import { import {
ApiTags, ApiTags,
@ -32,16 +29,6 @@ import {
import { JwtAuthGuard } from '../guards/jwt-auth.guard'; import { JwtAuthGuard } from '../guards/jwt-auth.guard';
import { Public } from '../decorators/public.decorator'; import { Public } from '../decorators/public.decorator';
import { CsvBookingService } from '../services/csv-booking.service'; import { CsvBookingService } from '../services/csv-booking.service';
import { SubscriptionService } from '../services/subscription.service';
import {
ShipmentCounterPort,
SHIPMENT_COUNTER_PORT,
} from '@domain/ports/out/shipment-counter.port';
import {
OrganizationRepository,
ORGANIZATION_REPOSITORY,
} from '@domain/ports/out/organization.repository';
import { ShipmentLimitExceededException } from '@domain/exceptions/shipment-limit-exceeded.exception';
import { import {
CreateCsvBookingDto, CreateCsvBookingDto,
CsvBookingResponseDto, CsvBookingResponseDto,
@ -61,15 +48,7 @@ import {
@ApiTags('CSV Bookings') @ApiTags('CSV Bookings')
@Controller('csv-bookings') @Controller('csv-bookings')
export class CsvBookingsController { export class CsvBookingsController {
constructor( constructor(private readonly csvBookingService: CsvBookingService) {}
private readonly csvBookingService: CsvBookingService,
private readonly subscriptionService: SubscriptionService,
private readonly configService: ConfigService,
@Inject(SHIPMENT_COUNTER_PORT)
private readonly shipmentCounter: ShipmentCounterPort,
@Inject(ORGANIZATION_REPOSITORY)
private readonly organizationRepository: OrganizationRepository
) {}
// ============================================================================ // ============================================================================
// STATIC ROUTES (must come FIRST) // STATIC ROUTES (must come FIRST)
@ -81,6 +60,7 @@ export class CsvBookingsController {
* POST /api/v1/csv-bookings * POST /api/v1/csv-bookings
*/ */
@Post() @Post()
@UseGuards(JwtAuthGuard)
@ApiBearerAuth() @ApiBearerAuth()
@UseInterceptors(FilesInterceptor('documents', 10)) @UseInterceptors(FilesInterceptor('documents', 10))
@ApiConsumes('multipart/form-data') @ApiConsumes('multipart/form-data')
@ -164,23 +144,6 @@ export class CsvBookingsController {
const userId = req.user.id; const userId = req.user.id;
const organizationId = req.user.organizationId; const organizationId = req.user.organizationId;
// 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);
}
}
}
// Convert string values to numbers (multipart/form-data sends everything as strings) // Convert string values to numbers (multipart/form-data sends everything as strings)
const sanitizedDto: CreateCsvBookingDto = { const sanitizedDto: CreateCsvBookingDto = {
...dto, ...dto,
@ -378,126 +341,6 @@ export class CsvBookingsController {
}; };
} }
/**
* Create Stripe Checkout session for commission payment
*
* POST /api/v1/csv-bookings/:id/pay
*/
@Post(':id/pay')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({
summary: 'Pay commission for a booking',
description:
'Creates a Stripe Checkout session for the commission payment. Returns the Stripe session URL to redirect the user to.',
})
@ApiParam({ name: 'id', description: 'Booking ID (UUID)' })
@ApiResponse({
status: 200,
description: 'Stripe checkout session created',
schema: {
type: 'object',
properties: {
sessionUrl: { type: 'string' },
sessionId: { type: 'string' },
commissionAmountEur: { type: 'number' },
},
},
})
@ApiResponse({ status: 400, description: 'Booking not in PENDING_PAYMENT status' })
@ApiResponse({ status: 404, description: 'Booking not found' })
async payCommission(@Param('id') id: string, @Request() req: any) {
const userId = req.user.id;
const userEmail = req.user.email;
const organizationId = req.user.organizationId;
const frontendUrl = this.configService.get<string>('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);
}
/**
* Confirm commission payment after Stripe redirect
*
* POST /api/v1/csv-bookings/:id/confirm-payment
*/
@Post(':id/confirm-payment')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({
summary: 'Confirm commission payment',
description:
'Called after Stripe payment success. Verifies the payment, updates booking to PENDING, sends email to carrier.',
})
@ApiParam({ name: 'id', description: 'Booking ID (UUID)' })
@ApiBody({
schema: {
type: 'object',
required: ['sessionId'],
properties: {
sessionId: { type: 'string', description: 'Stripe Checkout session ID' },
},
},
})
@ApiResponse({
status: 200,
description: 'Payment confirmed, booking activated',
type: CsvBookingResponseDto,
})
@ApiResponse({ status: 400, description: 'Payment not completed or session mismatch' })
@ApiResponse({ status: 404, description: 'Booking not found' })
async confirmPayment(
@Param('id') id: string,
@Body('sessionId') sessionId: string,
@Request() req: any
): Promise<CsvBookingResponseDto> {
if (!sessionId) {
throw new BadRequestException('sessionId is required');
}
const userId = req.user.id;
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<CsvBookingResponseDto> {
const userId = req.user.id;
return await this.csvBookingService.declareBankTransfer(id, userId);
}
// ============================================================================ // ============================================================================
// PARAMETERIZED ROUTES (must come LAST) // PARAMETERIZED ROUTES (must come LAST)
// ============================================================================ // ============================================================================

View File

@ -22,7 +22,12 @@ import { JwtAuthGuard } from '../guards/jwt-auth.guard';
import { CurrentUser } from '../decorators/current-user.decorator'; import { CurrentUser } from '../decorators/current-user.decorator';
import { UserPayload } from '../decorators/current-user.decorator'; import { UserPayload } from '../decorators/current-user.decorator';
import { GDPRService } from '../services/gdpr.service'; import { GDPRService } from '../services/gdpr.service';
import { UpdateConsentDto, ConsentResponseDto, WithdrawConsentDto } from '../dto/consent.dto'; import {
UpdateConsentDto,
ConsentResponseDto,
WithdrawConsentDto,
ConsentSuccessDto,
} from '../dto/consent.dto';
@ApiTags('GDPR') @ApiTags('GDPR')
@Controller('gdpr') @Controller('gdpr')

View File

@ -71,8 +71,7 @@ export class InvitationsController {
dto.lastName, dto.lastName,
dto.role as unknown as UserRole, dto.role as unknown as UserRole,
user.organizationId, user.organizationId,
user.id, user.id
user.role
); );
return { return {

View File

@ -22,8 +22,6 @@ import {
Headers, Headers,
RawBodyRequest, RawBodyRequest,
Req, Req,
Inject,
ForbiddenException,
} from '@nestjs/common'; } from '@nestjs/common';
import { import {
ApiTags, ApiTags,
@ -49,21 +47,13 @@ import { RolesGuard } from '../guards/roles.guard';
import { Roles } from '../decorators/roles.decorator'; import { Roles } from '../decorators/roles.decorator';
import { CurrentUser, UserPayload } from '../decorators/current-user.decorator'; import { CurrentUser, UserPayload } from '../decorators/current-user.decorator';
import { Public } from '../decorators/public.decorator'; import { Public } from '../decorators/public.decorator';
import {
OrganizationRepository,
ORGANIZATION_REPOSITORY,
} from '@domain/ports/out/organization.repository';
@ApiTags('Subscriptions') @ApiTags('Subscriptions')
@Controller('subscriptions') @Controller('subscriptions')
export class SubscriptionsController { export class SubscriptionsController {
private readonly logger = new Logger(SubscriptionsController.name); private readonly logger = new Logger(SubscriptionsController.name);
constructor( constructor(private readonly subscriptionService: SubscriptionService) {}
private readonly subscriptionService: SubscriptionService,
@Inject(ORGANIZATION_REPOSITORY)
private readonly organizationRepository: OrganizationRepository
) {}
/** /**
* Get subscription overview for current organization * Get subscription overview for current organization
@ -87,10 +77,10 @@ export class SubscriptionsController {
description: 'Forbidden - requires admin or manager role', description: 'Forbidden - requires admin or manager role',
}) })
async getSubscriptionOverview( async getSubscriptionOverview(
@CurrentUser() user: UserPayload @CurrentUser() user: UserPayload,
): Promise<SubscriptionOverviewResponseDto> { ): Promise<SubscriptionOverviewResponseDto> {
this.logger.log(`[User: ${user.email}] Getting subscription overview`); this.logger.log(`[User: ${user.email}] Getting subscription overview`);
return this.subscriptionService.getSubscriptionOverview(user.organizationId, user.role); return this.subscriptionService.getSubscriptionOverview(user.organizationId);
} }
/** /**
@ -136,7 +126,7 @@ export class SubscriptionsController {
}) })
async canInvite(@CurrentUser() user: UserPayload): Promise<CanInviteResponseDto> { async canInvite(@CurrentUser() user: UserPayload): Promise<CanInviteResponseDto> {
this.logger.log(`[User: ${user.email}] Checking license availability`); this.logger.log(`[User: ${user.email}] Checking license availability`);
return this.subscriptionService.canInviteUser(user.organizationId, user.role); return this.subscriptionService.canInviteUser(user.organizationId);
} }
/** /**
@ -149,7 +139,8 @@ export class SubscriptionsController {
@ApiBearerAuth() @ApiBearerAuth()
@ApiOperation({ @ApiOperation({
summary: 'Create checkout session', summary: 'Create checkout session',
description: 'Create a Stripe Checkout session for upgrading subscription. Admin/Manager only.', description:
'Create a Stripe Checkout session for upgrading subscription. Admin/Manager only.',
}) })
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
@ -166,22 +157,14 @@ export class SubscriptionsController {
}) })
async createCheckoutSession( async createCheckoutSession(
@Body() dto: CreateCheckoutSessionDto, @Body() dto: CreateCheckoutSessionDto,
@CurrentUser() user: UserPayload @CurrentUser() user: UserPayload,
): Promise<CheckoutSessionResponseDto> { ): Promise<CheckoutSessionResponseDto> {
this.logger.log(`[User: ${user.email}] Creating checkout session for plan: ${dto.plan}`); this.logger.log(`[User: ${user.email}] Creating checkout session for plan: ${dto.plan}`);
return this.subscriptionService.createCheckoutSession(
// ADMIN users bypass all payment restrictions user.organizationId,
if (user.role !== 'ADMIN') { user.id,
// SIRET verification gate: organization must have a verified SIRET before purchasing dto,
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);
} }
/** /**
@ -212,7 +195,7 @@ export class SubscriptionsController {
}) })
async createPortalSession( async createPortalSession(
@Body() dto: CreatePortalSessionDto, @Body() dto: CreatePortalSessionDto,
@CurrentUser() user: UserPayload @CurrentUser() user: UserPayload,
): Promise<PortalSessionResponseDto> { ): Promise<PortalSessionResponseDto> {
this.logger.log(`[User: ${user.email}] Creating portal session`); this.logger.log(`[User: ${user.email}] Creating portal session`);
return this.subscriptionService.createPortalSession(user.organizationId, dto); return this.subscriptionService.createPortalSession(user.organizationId, dto);
@ -247,10 +230,10 @@ export class SubscriptionsController {
}) })
async syncFromStripe( async syncFromStripe(
@Body() dto: SyncSubscriptionDto, @Body() dto: SyncSubscriptionDto,
@CurrentUser() user: UserPayload @CurrentUser() user: UserPayload,
): Promise<SubscriptionOverviewResponseDto> { ): Promise<SubscriptionOverviewResponseDto> {
this.logger.log( this.logger.log(
`[User: ${user.email}] Syncing subscription from Stripe${dto.sessionId ? ` (sessionId: ${dto.sessionId})` : ''}` `[User: ${user.email}] Syncing subscription from Stripe${dto.sessionId ? ` (sessionId: ${dto.sessionId})` : ''}`,
); );
return this.subscriptionService.syncFromStripe(user.organizationId, dto.sessionId); return this.subscriptionService.syncFromStripe(user.organizationId, dto.sessionId);
} }
@ -264,7 +247,7 @@ export class SubscriptionsController {
@ApiExcludeEndpoint() @ApiExcludeEndpoint()
async handleWebhook( async handleWebhook(
@Headers('stripe-signature') signature: string, @Headers('stripe-signature') signature: string,
@Req() req: RawBodyRequest<Request> @Req() req: RawBodyRequest<Request>,
): Promise<{ received: boolean }> { ): Promise<{ received: boolean }> {
const rawBody = req.rawBody; const rawBody = req.rawBody;
if (!rawBody) { if (!rawBody) {

View File

@ -44,10 +44,8 @@ import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.reposito
import { User, UserRole as DomainUserRole } from '@domain/entities/user.entity'; import { User, UserRole as DomainUserRole } from '@domain/entities/user.entity';
import { JwtAuthGuard } from '../guards/jwt-auth.guard'; import { JwtAuthGuard } from '../guards/jwt-auth.guard';
import { RolesGuard } from '../guards/roles.guard'; import { RolesGuard } from '../guards/roles.guard';
import { FeatureFlagGuard } from '../guards/feature-flag.guard';
import { CurrentUser, UserPayload } from '../decorators/current-user.decorator'; import { CurrentUser, UserPayload } from '../decorators/current-user.decorator';
import { Roles } from '../decorators/roles.decorator'; import { Roles } from '../decorators/roles.decorator';
import { RequiresFeature } from '../decorators/requires-feature.decorator';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import * as argon2 from 'argon2'; import * as argon2 from 'argon2';
import * as crypto from 'crypto'; import * as crypto from 'crypto';
@ -66,15 +64,14 @@ import { SubscriptionService } from '../services/subscription.service';
*/ */
@ApiTags('Users') @ApiTags('Users')
@Controller('users') @Controller('users')
@UseGuards(JwtAuthGuard, RolesGuard, FeatureFlagGuard) @UseGuards(JwtAuthGuard, RolesGuard)
@RequiresFeature('user_management')
@ApiBearerAuth() @ApiBearerAuth()
export class UsersController { export class UsersController {
private readonly logger = new Logger(UsersController.name); private readonly logger = new Logger(UsersController.name);
constructor( constructor(
@Inject(USER_REPOSITORY) private readonly userRepository: UserRepository, @Inject(USER_REPOSITORY) private readonly userRepository: UserRepository,
private readonly subscriptionService: SubscriptionService private readonly subscriptionService: SubscriptionService,
) {} ) {}
/** /**
@ -287,7 +284,7 @@ export class UsersController {
} catch (error) { } catch (error) {
this.logger.error(`Failed to reallocate license for user ${id}:`, error); this.logger.error(`Failed to reallocate license for user ${id}:`, error);
throw new ForbiddenException( throw new ForbiddenException(
'Cannot reactivate user: no licenses available. Please upgrade your subscription.' 'Cannot reactivate user: no licenses available. Please upgrade your subscription.',
); );
} }
} else { } else {

View File

@ -1,24 +1,13 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule } from '@nestjs/config';
import { CsvBookingsController } from './controllers/csv-bookings.controller'; import { CsvBookingsController } from './controllers/csv-bookings.controller';
import { CsvBookingActionsController } from './controllers/csv-booking-actions.controller'; import { CsvBookingActionsController } from './controllers/csv-booking-actions.controller';
import { CsvBookingService } from './services/csv-booking.service'; import { CsvBookingService } from './services/csv-booking.service';
import { CsvBookingOrmEntity } from '../infrastructure/persistence/typeorm/entities/csv-booking.orm-entity'; import { CsvBookingOrmEntity } from '../infrastructure/persistence/typeorm/entities/csv-booking.orm-entity';
import { TypeOrmCsvBookingRepository } from '../infrastructure/persistence/typeorm/repositories/csv-booking.repository'; 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 { NotificationsModule } from './notifications/notifications.module';
import { EmailModule } from '../infrastructure/email/email.module'; import { EmailModule } from '../infrastructure/email/email.module';
import { StorageModule } from '../infrastructure/storage/storage.module'; import { StorageModule } from '../infrastructure/storage/storage.module';
import { SubscriptionsModule } from './subscriptions/subscriptions.module';
import { StripeModule } from '../infrastructure/stripe/stripe.module';
/** /**
* CSV Bookings Module * CSV Bookings Module
@ -27,31 +16,13 @@ import { StripeModule } from '../infrastructure/stripe/stripe.module';
*/ */
@Module({ @Module({
imports: [ imports: [
TypeOrmModule.forFeature([CsvBookingOrmEntity, OrganizationOrmEntity, UserOrmEntity]), TypeOrmModule.forFeature([CsvBookingOrmEntity]),
ConfigModule,
NotificationsModule, NotificationsModule,
EmailModule, EmailModule,
StorageModule, StorageModule,
SubscriptionsModule,
StripeModule,
], ],
controllers: [CsvBookingsController, CsvBookingActionsController], controllers: [CsvBookingsController, CsvBookingActionsController],
providers: [ providers: [CsvBookingService, TypeOrmCsvBookingRepository],
CsvBookingService,
TypeOrmCsvBookingRepository,
{
provide: SHIPMENT_COUNTER_PORT,
useClass: TypeOrmShipmentCounterRepository,
},
{
provide: ORGANIZATION_REPOSITORY,
useClass: TypeOrmOrganizationRepository,
},
{
provide: USER_REPOSITORY,
useClass: TypeOrmUserRepository,
},
],
exports: [CsvBookingService, TypeOrmCsvBookingRepository], exports: [CsvBookingService, TypeOrmCsvBookingRepository],
}) })
export class CsvBookingsModule {} export class CsvBookingsModule {}

View File

@ -7,12 +7,9 @@
import { Controller, Get, UseGuards, Request } from '@nestjs/common'; import { Controller, Get, UseGuards, Request } from '@nestjs/common';
import { AnalyticsService } from '../services/analytics.service'; import { AnalyticsService } from '../services/analytics.service';
import { JwtAuthGuard } from '../guards/jwt-auth.guard'; import { JwtAuthGuard } from '../guards/jwt-auth.guard';
import { FeatureFlagGuard } from '../guards/feature-flag.guard';
import { RequiresFeature } from '../decorators/requires-feature.decorator';
@Controller('dashboard') @Controller('dashboard')
@UseGuards(JwtAuthGuard, FeatureFlagGuard) @UseGuards(JwtAuthGuard)
@RequiresFeature('dashboard')
export class DashboardController { export class DashboardController {
constructor(private readonly analyticsService: AnalyticsService) {} constructor(private readonly analyticsService: AnalyticsService) {}

View File

@ -8,13 +8,11 @@ import { AnalyticsService } from '../services/analytics.service';
import { BookingsModule } from '../bookings/bookings.module'; import { BookingsModule } from '../bookings/bookings.module';
import { RatesModule } from '../rates/rates.module'; import { RatesModule } from '../rates/rates.module';
import { CsvBookingsModule } from '../csv-bookings.module'; import { CsvBookingsModule } from '../csv-bookings.module';
import { SubscriptionsModule } from '../subscriptions/subscriptions.module';
import { FeatureFlagGuard } from '../guards/feature-flag.guard';
@Module({ @Module({
imports: [BookingsModule, RatesModule, CsvBookingsModule, SubscriptionsModule], imports: [BookingsModule, RatesModule, CsvBookingsModule],
controllers: [DashboardController], controllers: [DashboardController],
providers: [AnalyticsService, FeatureFlagGuard], providers: [AnalyticsService],
exports: [AnalyticsService], exports: [AnalyticsService],
}) })
export class DashboardModule {} export class DashboardModule {}

View File

@ -1,15 +0,0 @@
import { SetMetadata } from '@nestjs/common';
import { PlanFeature } from '@domain/value-objects/plan-feature.vo';
export const REQUIRED_FEATURES_KEY = 'requiredFeatures';
/**
* Decorator to require specific plan features for a route.
* Works with FeatureFlagGuard to enforce access control.
*
* Usage:
* @RequiresFeature('dashboard')
* @RequiresFeature('csv_export', 'api_access')
*/
export const RequiresFeature = (...features: PlanFeature[]) =>
SetMetadata(REQUIRED_FEATURES_KEY, features);

View File

@ -94,18 +94,6 @@ export class RegisterOrganizationDto {
@Matches(/^[A-Z]{2}$/, { message: 'Country must be a 2-letter ISO code (e.g., NL, US, CN)' }) @Matches(/^[A-Z]{2}$/, { message: 'Country must be a 2-letter ISO code (e.g., NL, US, CN)' })
country: string; 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({ @ApiPropertyOptional({
example: 'MAEU', example: 'MAEU',
description: 'Standard Carrier Alpha Code (4 uppercase letters, required for carriers only)', description: 'Standard Carrier Alpha Code (4 uppercase letters, required for carriers only)',

View File

@ -1,118 +1,112 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator'; import { IsNotEmpty, IsString } from 'class-validator';
/** /**
* DTO for verifying document access password * DTO for verifying document access password
*/ */
export class VerifyDocumentAccessDto { export class VerifyDocumentAccessDto {
@ApiProperty({ description: 'Password for document access (booking number code)' }) @ApiProperty({ description: 'Password for document access (booking number code)' })
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
password: string; password: string;
} }
/** /**
* Response DTO for checking document access requirements * Response DTO for checking document access requirements
*/ */
export class DocumentAccessRequirementsDto { export class DocumentAccessRequirementsDto {
@ApiProperty({ description: 'Whether password is required to access documents' }) @ApiProperty({ description: 'Whether password is required to access documents' })
requiresPassword: boolean; requiresPassword: boolean;
@ApiPropertyOptional({ description: 'Booking number (if available)' }) @ApiPropertyOptional({ description: 'Booking number (if available)' })
bookingNumber?: string; bookingNumber?: string;
@ApiProperty({ description: 'Current booking status' }) @ApiProperty({ description: 'Current booking status' })
status: string; status: string;
} }
/** /**
* Booking Summary DTO for Carrier Documents Page * Booking Summary DTO for Carrier Documents Page
*/ */
export class BookingSummaryDto { export class BookingSummaryDto {
@ApiProperty({ description: 'Booking unique ID' }) @ApiProperty({ description: 'Booking unique ID' })
id: string; id: string;
@ApiPropertyOptional({ description: 'Human-readable booking number' }) @ApiPropertyOptional({ description: 'Human-readable booking number' })
bookingNumber?: string; bookingNumber?: string;
@ApiProperty({ description: 'Carrier/Company name' }) @ApiProperty({ description: 'Carrier/Company name' })
carrierName: string; carrierName: string;
@ApiProperty({ description: 'Origin port code' }) @ApiProperty({ description: 'Origin port code' })
origin: string; origin: string;
@ApiProperty({ description: 'Destination port code' }) @ApiProperty({ description: 'Destination port code' })
destination: string; destination: string;
@ApiProperty({ description: 'Route description (origin -> destination)' }) @ApiProperty({ description: 'Route description (origin -> destination)' })
routeDescription: string; routeDescription: string;
@ApiProperty({ description: 'Volume in CBM' }) @ApiProperty({ description: 'Volume in CBM' })
volumeCBM: number; volumeCBM: number;
@ApiProperty({ description: 'Weight in KG' }) @ApiProperty({ description: 'Weight in KG' })
weightKG: number; weightKG: number;
@ApiProperty({ description: 'Number of pallets' }) @ApiProperty({ description: 'Number of pallets' })
palletCount: number; palletCount: number;
@ApiProperty({ description: 'Price in the primary currency' }) @ApiProperty({ description: 'Price in the primary currency' })
price: number; price: number;
@ApiProperty({ description: 'Currency (USD or EUR)' }) @ApiProperty({ description: 'Currency (USD or EUR)' })
currency: string; currency: string;
@ApiProperty({ description: 'Transit time in days' }) @ApiProperty({ description: 'Transit time in days' })
transitDays: number; transitDays: number;
@ApiProperty({ description: 'Container type' }) @ApiProperty({ description: 'Container type' })
containerType: string; containerType: string;
@ApiProperty({ description: 'When the booking was accepted' }) @ApiProperty({ description: 'When the booking was accepted' })
acceptedAt: Date; acceptedAt: Date;
} }
/** /**
* Document with signed download URL for carrier access * Document with signed download URL for carrier access
*/ */
export class DocumentWithUrlDto { export class DocumentWithUrlDto {
@ApiProperty({ description: 'Document unique ID' }) @ApiProperty({ description: 'Document unique ID' })
id: string; id: string;
@ApiProperty({ @ApiProperty({
description: 'Document type', description: 'Document type',
enum: [ enum: ['BILL_OF_LADING', 'PACKING_LIST', 'COMMERCIAL_INVOICE', 'CERTIFICATE_OF_ORIGIN', 'OTHER'],
'BILL_OF_LADING', })
'PACKING_LIST', type: string;
'COMMERCIAL_INVOICE',
'CERTIFICATE_OF_ORIGIN', @ApiProperty({ description: 'Original file name' })
'OTHER', fileName: string;
],
}) @ApiProperty({ description: 'File MIME type' })
type: string; mimeType: string;
@ApiProperty({ description: 'Original file name' }) @ApiProperty({ description: 'File size in bytes' })
fileName: string; size: number;
@ApiProperty({ description: 'File MIME type' }) @ApiProperty({ description: 'Temporary signed download URL (valid for 1 hour)' })
mimeType: string; downloadUrl: string;
}
@ApiProperty({ description: 'File size in bytes' })
size: number; /**
* Carrier Documents Response DTO
@ApiProperty({ description: 'Temporary signed download URL (valid for 1 hour)' }) *
downloadUrl: string; * Response for carrier document access page
} */
export class CarrierDocumentsResponseDto {
/** @ApiProperty({ description: 'Booking summary information', type: BookingSummaryDto })
* Carrier Documents Response DTO booking: BookingSummaryDto;
*
* Response for carrier document access page @ApiProperty({ description: 'List of documents with download URLs', type: [DocumentWithUrlDto] })
*/ documents: DocumentWithUrlDto[];
export class CarrierDocumentsResponseDto { }
@ApiProperty({ description: 'Booking summary information', type: BookingSummaryDto })
booking: BookingSummaryDto;
@ApiProperty({ description: 'List of documents with download URLs', type: [DocumentWithUrlDto] })
documents: DocumentWithUrlDto[];
}

View File

@ -1,139 +1,139 @@
/** /**
* Cookie Consent DTOs * Cookie Consent DTOs
* GDPR compliant consent management * GDPR compliant consent management
*/ */
import { IsBoolean, IsOptional, IsString, IsEnum } from 'class-validator'; import { IsBoolean, IsOptional, IsString, IsEnum, IsDateString, IsIP } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
/** /**
* Request DTO for recording/updating cookie consent * Request DTO for recording/updating cookie consent
*/ */
export class UpdateConsentDto { export class UpdateConsentDto {
@ApiProperty({ @ApiProperty({
example: true, example: true,
description: 'Essential cookies consent (always true, required for functionality)', description: 'Essential cookies consent (always true, required for functionality)',
default: true, default: true,
}) })
@IsBoolean() @IsBoolean()
essential: boolean; essential: boolean;
@ApiProperty({ @ApiProperty({
example: false, example: false,
description: 'Functional cookies consent (preferences, language, etc.)', description: 'Functional cookies consent (preferences, language, etc.)',
default: false, default: false,
}) })
@IsBoolean() @IsBoolean()
functional: boolean; functional: boolean;
@ApiProperty({ @ApiProperty({
example: false, example: false,
description: 'Analytics cookies consent (Google Analytics, Sentry, etc.)', description: 'Analytics cookies consent (Google Analytics, Sentry, etc.)',
default: false, default: false,
}) })
@IsBoolean() @IsBoolean()
analytics: boolean; analytics: boolean;
@ApiProperty({ @ApiProperty({
example: false, example: false,
description: 'Marketing cookies consent (ads, tracking, remarketing)', description: 'Marketing cookies consent (ads, tracking, remarketing)',
default: false, default: false,
}) })
@IsBoolean() @IsBoolean()
marketing: boolean; marketing: boolean;
@ApiPropertyOptional({ @ApiPropertyOptional({
example: '192.168.1.1', example: '192.168.1.1',
description: 'IP address at time of consent (for GDPR audit trail)', description: 'IP address at time of consent (for GDPR audit trail)',
}) })
@IsOptional() @IsOptional()
@IsString() @IsString()
ipAddress?: string; ipAddress?: string;
@ApiPropertyOptional({ @ApiPropertyOptional({
example: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', example: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
description: 'User agent at time of consent', description: 'User agent at time of consent',
}) })
@IsOptional() @IsOptional()
@IsString() @IsString()
userAgent?: string; userAgent?: string;
} }
/** /**
* Response DTO for consent status * Response DTO for consent status
*/ */
export class ConsentResponseDto { export class ConsentResponseDto {
@ApiProperty({ @ApiProperty({
example: '550e8400-e29b-41d4-a716-446655440000', example: '550e8400-e29b-41d4-a716-446655440000',
description: 'User ID', description: 'User ID',
}) })
userId: string; userId: string;
@ApiProperty({ @ApiProperty({
example: true, example: true,
description: 'Essential cookies consent (always true)', description: 'Essential cookies consent (always true)',
}) })
essential: boolean; essential: boolean;
@ApiProperty({ @ApiProperty({
example: false, example: false,
description: 'Functional cookies consent', description: 'Functional cookies consent',
}) })
functional: boolean; functional: boolean;
@ApiProperty({ @ApiProperty({
example: false, example: false,
description: 'Analytics cookies consent', description: 'Analytics cookies consent',
}) })
analytics: boolean; analytics: boolean;
@ApiProperty({ @ApiProperty({
example: false, example: false,
description: 'Marketing cookies consent', description: 'Marketing cookies consent',
}) })
marketing: boolean; marketing: boolean;
@ApiProperty({ @ApiProperty({
example: '2025-01-27T10:30:00.000Z', example: '2025-01-27T10:30:00.000Z',
description: 'Date when consent was recorded', description: 'Date when consent was recorded',
}) })
consentDate: Date; consentDate: Date;
@ApiProperty({ @ApiProperty({
example: '2025-01-27T10:30:00.000Z', example: '2025-01-27T10:30:00.000Z',
description: 'Last update timestamp', description: 'Last update timestamp',
}) })
updatedAt: Date; updatedAt: Date;
} }
/** /**
* Request DTO for withdrawing specific consent * Request DTO for withdrawing specific consent
*/ */
export class WithdrawConsentDto { export class WithdrawConsentDto {
@ApiProperty({ @ApiProperty({
example: 'marketing', example: 'marketing',
description: 'Type of consent to withdraw', description: 'Type of consent to withdraw',
enum: ['functional', 'analytics', 'marketing'], enum: ['functional', 'analytics', 'marketing'],
}) })
@IsEnum(['functional', 'analytics', 'marketing'], { @IsEnum(['functional', 'analytics', 'marketing'], {
message: 'Consent type must be functional, analytics, or marketing', message: 'Consent type must be functional, analytics, or marketing',
}) })
consentType: 'functional' | 'analytics' | 'marketing'; consentType: 'functional' | 'analytics' | 'marketing';
} }
/** /**
* Success response DTO * Success response DTO
*/ */
export class ConsentSuccessDto { export class ConsentSuccessDto {
@ApiProperty({ @ApiProperty({
example: true, example: true,
description: 'Operation success status', description: 'Operation success status',
}) })
success: boolean; success: boolean;
@ApiProperty({ @ApiProperty({
example: 'Consent preferences saved successfully', example: 'Consent preferences saved successfully',
description: 'Response message', description: 'Response message',
}) })
message: string; message: string;
} }

View File

@ -294,8 +294,8 @@ export class CsvBookingResponseDto {
@ApiProperty({ @ApiProperty({
description: 'Booking status', description: 'Booking status',
enum: ['PENDING_PAYMENT', 'PENDING', 'ACCEPTED', 'REJECTED', 'CANCELLED'], enum: ['PENDING', 'ACCEPTED', 'REJECTED', 'CANCELLED'],
example: 'PENDING_PAYMENT', example: 'PENDING',
}) })
status: string; status: string;
@ -353,18 +353,6 @@ export class CsvBookingResponseDto {
example: 1850.5, example: 1850.5,
}) })
price: number; price: number;
@ApiPropertyOptional({
description: 'Commission rate in percent',
example: 5,
})
commissionRate?: number;
@ApiPropertyOptional({
description: 'Commission amount in EUR',
example: 313.27,
})
commissionAmountEur?: number;
} }
/** /**
@ -426,12 +414,6 @@ export class CsvBookingListResponseDto {
* Statistics for user's or organization's bookings * Statistics for user's or organization's bookings
*/ */
export class CsvBookingStatsDto { export class CsvBookingStatsDto {
@ApiProperty({
description: 'Number of bookings awaiting payment',
example: 1,
})
pendingPayment: number;
@ApiProperty({ @ApiProperty({
description: 'Number of pending bookings', description: 'Number of pending bookings',
example: 5, example: 5,

View File

@ -184,19 +184,6 @@ export class UpdateOrganizationDto {
@Matches(/^[0-9]{9}$/, { message: 'SIREN must be 9 digits' }) @Matches(/^[0-9]{9}$/, { message: 'SIREN must be 9 digits' })
siren?: string; 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({ @ApiPropertyOptional({
example: 'FR123456789', example: 'FR123456789',
description: 'EU EORI number', description: 'EU EORI number',
@ -357,25 +344,6 @@ export class OrganizationResponseDto {
}) })
documents: OrganizationDocumentDto[]; 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({ @ApiProperty({
example: true, example: true,
description: 'Active status', description: 'Active status',

View File

@ -5,16 +5,25 @@
*/ */
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsString, IsEnum, IsUrl, IsOptional } from 'class-validator'; import {
IsString,
IsEnum,
IsNotEmpty,
IsUrl,
IsOptional,
IsBoolean,
IsInt,
Min,
} from 'class-validator';
/** /**
* Subscription plan types * Subscription plan types
*/ */
export enum SubscriptionPlanDto { export enum SubscriptionPlanDto {
BRONZE = 'BRONZE', FREE = 'FREE',
SILVER = 'SILVER', STARTER = 'STARTER',
GOLD = 'GOLD', PRO = 'PRO',
PLATINIUM = 'PLATINIUM', ENTERPRISE = 'ENTERPRISE',
} }
/** /**
@ -44,7 +53,7 @@ export enum BillingIntervalDto {
*/ */
export class CreateCheckoutSessionDto { export class CreateCheckoutSessionDto {
@ApiProperty({ @ApiProperty({
example: SubscriptionPlanDto.SILVER, example: SubscriptionPlanDto.STARTER,
description: 'The subscription plan to purchase', description: 'The subscription plan to purchase',
enum: SubscriptionPlanDto, enum: SubscriptionPlanDto,
}) })
@ -188,14 +197,14 @@ export class LicenseResponseDto {
*/ */
export class PlanDetailsDto { export class PlanDetailsDto {
@ApiProperty({ @ApiProperty({
example: SubscriptionPlanDto.SILVER, example: SubscriptionPlanDto.STARTER,
description: 'Plan identifier', description: 'Plan identifier',
enum: SubscriptionPlanDto, enum: SubscriptionPlanDto,
}) })
plan: SubscriptionPlanDto; plan: SubscriptionPlanDto;
@ApiProperty({ @ApiProperty({
example: 'Silver', example: 'Starter',
description: 'Plan display name', description: 'Plan display name',
}) })
name: string; name: string;
@ -207,51 +216,20 @@ export class PlanDetailsDto {
maxLicenses: number; maxLicenses: number;
@ApiProperty({ @ApiProperty({
example: 249, example: 49,
description: 'Monthly price in EUR', description: 'Monthly price in EUR',
}) })
monthlyPriceEur: number; monthlyPriceEur: number;
@ApiProperty({ @ApiProperty({
example: 2739, example: 470,
description: 'Yearly price in EUR (11 months)', description: 'Yearly price in EUR',
}) })
yearlyPriceEur: number; yearlyPriceEur: number;
@ApiProperty({ @ApiProperty({
example: -1, example: ['Up to 5 users', 'Advanced rate search', 'CSV imports'],
description: 'Maximum shipments per year (-1 for unlimited)', description: 'List of features included in this plan',
})
maxShipmentsPerYear: number;
@ApiProperty({
example: 3,
description: 'Commission rate percentage on shipments',
})
commissionRatePercent: number;
@ApiProperty({
example: 'email',
description: 'Support level: none, email, direct, dedicated_kam',
})
supportLevel: string;
@ApiProperty({
example: 'silver',
description: 'Status badge: none, silver, gold, platinium',
})
statusBadge: string;
@ApiProperty({
example: ['dashboard', 'wiki', 'user_management', 'csv_export'],
description: 'List of plan feature flags',
type: [String],
})
planFeatures: string[];
@ApiProperty({
example: ["Jusqu'à 5 utilisateurs", 'Expéditions illimitées', 'Import CSV'],
description: 'List of human-readable features included in this plan',
type: [String], type: [String],
}) })
features: string[]; features: string[];
@ -274,7 +252,7 @@ export class SubscriptionResponseDto {
organizationId: string; organizationId: string;
@ApiProperty({ @ApiProperty({
example: SubscriptionPlanDto.SILVER, example: SubscriptionPlanDto.STARTER,
description: 'Current subscription plan', description: 'Current subscription plan',
enum: SubscriptionPlanDto, enum: SubscriptionPlanDto,
}) })

View File

@ -1,108 +0,0 @@
import {
Injectable,
CanActivate,
ExecutionContext,
ForbiddenException,
Inject,
Logger,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { PlanFeature } from '@domain/value-objects/plan-feature.vo';
import {
SubscriptionRepository,
SUBSCRIPTION_REPOSITORY,
} from '@domain/ports/out/subscription.repository';
import { REQUIRED_FEATURES_KEY } from '../decorators/requires-feature.decorator';
/**
* Feature Flag Guard
*
* Checks if the user's subscription plan includes the required features.
* First tries to read plan from JWT payload (fast path), falls back to DB lookup.
*
* Usage:
* @UseGuards(JwtAuthGuard, RolesGuard, FeatureFlagGuard)
* @RequiresFeature('dashboard')
*/
@Injectable()
export class FeatureFlagGuard implements CanActivate {
private readonly logger = new Logger(FeatureFlagGuard.name);
constructor(
private readonly reflector: Reflector,
@Inject(SUBSCRIPTION_REPOSITORY)
private readonly subscriptionRepository: SubscriptionRepository
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
// Get required features from @RequiresFeature() decorator
const requiredFeatures = this.reflector.getAllAndOverride<PlanFeature[]>(
REQUIRED_FEATURES_KEY,
[context.getHandler(), context.getClass()]
);
// If no features are required, allow access
if (!requiredFeatures || requiredFeatures.length === 0) {
return true;
}
const request = context.switchToHttp().getRequest();
const user = request.user;
if (!user || !user.organizationId) {
return false;
}
// 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));
if (hasAllFeatures) {
return true;
}
// JWT says no — but JWT might be stale after an upgrade.
// Fall through to DB check.
}
// Slow path: DB lookup for fresh subscription data
try {
const subscription = await this.subscriptionRepository.findByOrganizationId(
user.organizationId
);
if (!subscription) {
// No subscription means Bronze (free) plan — no premium features
this.throwFeatureRequired(requiredFeatures);
}
const plan = subscription!.plan;
const missingFeatures = requiredFeatures.filter(feature => !plan.hasFeature(feature));
if (missingFeatures.length > 0) {
this.throwFeatureRequired(requiredFeatures);
}
return true;
} catch (error) {
if (error instanceof ForbiddenException) {
throw error;
}
this.logger.error(`Failed to check subscription features: ${error}`);
// On DB error, deny access to premium features rather than 500
this.throwFeatureRequired(requiredFeatures);
}
}
private throwFeatureRequired(features: PlanFeature[]): never {
const featureNames = features.join(', ');
throw new ForbiddenException(
`Cette fonctionnalité nécessite un plan supérieur. Fonctionnalités requises : ${featureNames}. Passez à un plan Silver ou supérieur.`
);
}
}

View File

@ -31,9 +31,6 @@ export class OrganizationMapper {
address: this.mapAddressToDto(organization.address), address: this.mapAddressToDto(organization.address),
logoUrl: organization.logoUrl, logoUrl: organization.logoUrl,
documents: organization.documents.map(doc => this.mapDocumentToDto(doc)), documents: organization.documents.map(doc => this.mapDocumentToDto(doc)),
siret: organization.siret,
siretVerified: organization.siretVerified,
statusBadge: organization.statusBadge,
isActive: organization.isActive, isActive: organization.isActive,
createdAt: organization.createdAt, createdAt: organization.createdAt,
updatedAt: organization.updatedAt, updatedAt: organization.updatedAt,

View File

@ -16,9 +16,7 @@ import {
NOTIFICATION_REPOSITORY, NOTIFICATION_REPOSITORY,
} from '@domain/ports/out/notification.repository'; } from '@domain/ports/out/notification.repository';
import { EmailPort, EMAIL_PORT } from '@domain/ports/out/email.port'; 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 { StoragePort, STORAGE_PORT } from '@domain/ports/out/storage.port';
import { StripePort, STRIPE_PORT } from '@domain/ports/out/stripe.port';
import { import {
Notification, Notification,
NotificationType, NotificationType,
@ -32,7 +30,6 @@ import {
CsvBookingStatsDto, CsvBookingStatsDto,
} from '../dto/csv-booking.dto'; } from '../dto/csv-booking.dto';
import { CarrierDocumentsResponseDto } from '../dto/carrier-documents.dto'; import { CarrierDocumentsResponseDto } from '../dto/carrier-documents.dto';
import { SubscriptionService } from './subscription.service';
/** /**
* CSV Booking Document (simple class for domain) * CSV Booking Document (simple class for domain)
@ -65,12 +62,7 @@ export class CsvBookingService {
@Inject(EMAIL_PORT) @Inject(EMAIL_PORT)
private readonly emailAdapter: EmailPort, private readonly emailAdapter: EmailPort,
@Inject(STORAGE_PORT) @Inject(STORAGE_PORT)
private readonly storageAdapter: StoragePort, private readonly storageAdapter: StoragePort
@Inject(STRIPE_PORT)
private readonly stripeAdapter: StripePort,
private readonly subscriptionService: SubscriptionService,
@Inject(USER_REPOSITORY)
private readonly userRepository: UserRepository
) {} ) {}
/** /**
@ -122,18 +114,7 @@ export class CsvBookingService {
// Upload documents to S3 // Upload documents to S3
const documents = await this.uploadDocuments(files, bookingId); const documents = await this.uploadDocuments(files, bookingId);
// Calculate commission based on organization's subscription plan // Create domain entity
let commissionRate = 5; // default Bronze
let commissionAmountEur = 0;
try {
const subscription = await this.subscriptionService.getOrCreateSubscription(organizationId);
commissionRate = subscription.plan.commissionRatePercent;
} catch (error: any) {
this.logger.error(`Failed to get subscription for commission: ${error?.message}`);
}
commissionAmountEur = Math.round(dto.priceEUR * commissionRate) / 100;
// Create domain entity in PENDING_PAYMENT status (no email sent yet)
const booking = new CsvBooking( const booking = new CsvBooking(
bookingId, bookingId,
userId, userId,
@ -150,16 +131,12 @@ export class CsvBookingService {
dto.primaryCurrency, dto.primaryCurrency,
dto.transitDays, dto.transitDays,
dto.containerType, dto.containerType,
CsvBookingStatus.PENDING_PAYMENT, CsvBookingStatus.PENDING,
documents, documents,
confirmationToken, confirmationToken,
new Date(), new Date(),
undefined, undefined,
dto.notes, dto.notes
undefined,
bookingNumber,
commissionRate,
commissionAmountEur
); );
// Save to database // Save to database
@ -175,354 +152,58 @@ export class CsvBookingService {
await this.csvBookingRepository['repository'].save(ormBooking); await this.csvBookingRepository['repository'].save(ormBooking);
} }
this.logger.log( this.logger.log(`CSV booking created with ID: ${bookingId}, number: ${bookingNumber}`);
`CSV booking created with ID: ${bookingId}, number: ${bookingNumber}, status: PENDING_PAYMENT, commission: ${commissionRate}% = ${commissionAmountEur}`
);
// NO email sent to carrier yet - will be sent after commission payment // Send email to carrier and WAIT for confirmation
// NO notification yet - will be created after payment confirmation // The button waits for the email to be sent before responding
return this.toResponseDto(savedBooking);
}
/**
* Create a Stripe Checkout session for commission payment
*/
async createCommissionPayment(
bookingId: string,
userId: string,
userEmail: string,
frontendUrl: string
): Promise<{ sessionUrl: string; sessionId: string; commissionAmountEur: number }> {
const booking = await this.csvBookingRepository.findById(bookingId);
if (!booking) {
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
}
if (booking.userId !== userId) {
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
}
if (booking.status !== CsvBookingStatus.PENDING_PAYMENT) {
throw new BadRequestException(
`Booking is not awaiting payment. Current status: ${booking.status}`
);
}
const commissionAmountEur = booking.commissionAmountEur || 0;
if (commissionAmountEur <= 0) {
throw new BadRequestException('Commission amount is invalid');
}
const amountCents = Math.round(commissionAmountEur * 100);
const result = await this.stripeAdapter.createCommissionCheckout({
bookingId: booking.id,
amountCents,
currency: 'eur',
customerEmail: userEmail,
organizationId: booking.organizationId,
bookingDescription: `Commission booking ${booking.bookingNumber || booking.id} - ${booking.origin.getValue()}${booking.destination.getValue()}`,
successUrl: `${frontendUrl}/dashboard/booking/${booking.id}/payment-success?session_id={CHECKOUT_SESSION_ID}`,
cancelUrl: `${frontendUrl}/dashboard/booking/${booking.id}/pay`,
});
this.logger.log(
`Created Stripe commission checkout for booking ${bookingId}: ${amountCents} cents EUR`
);
return {
sessionUrl: result.sessionUrl,
sessionId: result.sessionId,
commissionAmountEur,
};
}
/**
* Confirm commission payment and activate booking
* Called after Stripe redirect with session_id
*/
async confirmCommissionPayment(
bookingId: string,
sessionId: string,
userId: string
): Promise<CsvBookingResponseDto> {
const booking = await this.csvBookingRepository.findById(bookingId);
if (!booking) {
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
}
if (booking.userId !== userId) {
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
}
if (booking.status !== CsvBookingStatus.PENDING_PAYMENT) {
// Already confirmed - return current state
if (booking.status === CsvBookingStatus.PENDING) {
return this.toResponseDto(booking);
}
throw new BadRequestException(
`Booking is not awaiting payment. Current status: ${booking.status}`
);
}
// Verify payment with Stripe
const session = await this.stripeAdapter.getCheckoutSession(sessionId);
if (!session || session.status !== 'complete') {
throw new BadRequestException('Payment has not been completed');
}
// Verify the session is for this booking
if (session.metadata?.bookingId !== bookingId) {
throw new BadRequestException('Payment session does not match this booking');
}
// Transition to PENDING
booking.markPaymentCompleted();
booking.stripePaymentIntentId = sessionId;
// Save updated booking
const updatedBooking = await this.csvBookingRepository.update(booking);
this.logger.log(`Booking ${bookingId} payment confirmed, status now PENDING`);
// Get ORM entity for booking number
const ormBooking = await this.csvBookingRepository['repository'].findOne({
where: { id: bookingId },
});
const bookingNumber = ormBooking?.bookingNumber;
const documentPassword = bookingNumber
? this.extractPasswordFromBookingNumber(bookingNumber)
: undefined;
// NOW send email to carrier
try { try {
await this.emailAdapter.sendCsvBookingRequest(booking.carrierEmail, { await this.emailAdapter.sendCsvBookingRequest(dto.carrierEmail, {
bookingId: booking.id, bookingId,
bookingNumber: bookingNumber || '', bookingNumber,
documentPassword: documentPassword || '', documentPassword,
origin: booking.origin.getValue(), origin: dto.origin,
destination: booking.destination.getValue(), destination: dto.destination,
volumeCBM: booking.volumeCBM, volumeCBM: dto.volumeCBM,
weightKG: booking.weightKG, weightKG: dto.weightKG,
palletCount: booking.palletCount, palletCount: dto.palletCount,
priceUSD: booking.priceUSD, priceUSD: dto.priceUSD,
priceEUR: booking.priceEUR, priceEUR: dto.priceEUR,
primaryCurrency: booking.primaryCurrency, primaryCurrency: dto.primaryCurrency,
transitDays: booking.transitDays, transitDays: dto.transitDays,
containerType: booking.containerType, containerType: dto.containerType,
documents: booking.documents.map(doc => ({ documents: documents.map(doc => ({
type: doc.type, type: doc.type,
fileName: doc.fileName, fileName: doc.fileName,
})), })),
confirmationToken: booking.confirmationToken, confirmationToken,
notes: booking.notes, notes: dto.notes,
}); });
this.logger.log(`Email sent to carrier: ${booking.carrierEmail}`); this.logger.log(`Email sent to carrier: ${dto.carrierEmail}`);
} catch (error: any) { } catch (error: any) {
this.logger.error(`Failed to send email to carrier: ${error?.message}`, error?.stack); this.logger.error(`Failed to send email to carrier: ${error?.message}`, error?.stack);
// Continue even if email fails - booking is already saved
} }
// Create notification for user // Create notification for user
try { try {
const notification = Notification.create({ const notification = Notification.create({
id: uuidv4(), id: uuidv4(),
userId: booking.userId, userId,
organizationId: booking.organizationId, organizationId,
type: NotificationType.CSV_BOOKING_REQUEST_SENT, type: NotificationType.CSV_BOOKING_REQUEST_SENT,
priority: NotificationPriority.MEDIUM, priority: NotificationPriority.MEDIUM,
title: 'Booking Request Sent', title: 'Booking Request Sent',
message: `Your booking request to ${booking.carrierName} for ${booking.getRouteDescription()} has been sent successfully after payment.`, message: `Your booking request to ${dto.carrierName} for ${dto.origin}${dto.destination} has been sent successfully.`,
metadata: { bookingId: booking.id, carrierName: booking.carrierName }, metadata: { bookingId, carrierName: dto.carrierName },
}); });
await this.notificationRepository.save(notification); await this.notificationRepository.save(notification);
this.logger.log(`Notification created for user ${userId}`);
} catch (error: any) { } catch (error: any) {
this.logger.error(`Failed to create notification: ${error?.message}`, error?.stack); this.logger.error(`Failed to create notification: ${error?.message}`, error?.stack);
// Continue even if notification fails
} }
return this.toResponseDto(updatedBooking); return this.toResponseDto(savedBooking);
}
/**
* 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<CsvBookingResponseDto> {
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: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #10183A;">Nouveau virement à valider</h2>
<p>Un client a déclaré avoir effectué un virement bancaire pour le booking suivant :</p>
<table style="width: 100%; border-collapse: collapse; margin: 16px 0;">
<tr style="background: #f5f5f5;">
<td style="padding: 8px 12px; font-weight: bold;">Numéro de booking</td>
<td style="padding: 8px 12px;">${bookingNumber}</td>
</tr>
<tr>
<td style="padding: 8px 12px; font-weight: bold;">Transporteur</td>
<td style="padding: 8px 12px;">${booking.carrierName}</td>
</tr>
<tr style="background: #f5f5f5;">
<td style="padding: 8px 12px; font-weight: bold;">Trajet</td>
<td style="padding: 8px 12px;">${booking.getRouteDescription()}</td>
</tr>
<tr>
<td style="padding: 8px 12px; font-weight: bold;">Montant commission</td>
<td style="padding: 8px 12px; color: #10183A; font-weight: bold;">${commissionAmount}</td>
</tr>
</table>
<p>Rendez-vous dans la <strong>console d'administration</strong> pour valider ce virement et activer le booking.</p>
<a href="${process.env.APP_URL || 'http://localhost:3000'}/dashboard/admin/bookings"
style="display: inline-block; background: #10183A; color: white; padding: 12px 24px; border-radius: 6px; text-decoration: none; margin-top: 8px;">
Voir les bookings en attente
</a>
</div>
`,
});
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<CsvBookingResponseDto> {
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);
} }
/** /**
@ -713,21 +394,6 @@ export class CsvBookingService {
// Accept the booking (domain logic validates status) // Accept the booking (domain logic validates status)
booking.accept(); booking.accept();
// Apply commission based on organization's subscription plan
try {
const subscription = await this.subscriptionService.getOrCreateSubscription(
booking.organizationId
);
const commissionRate = subscription.plan.commissionRatePercent;
const baseAmountEur = booking.priceEUR;
booking.applyCommission(commissionRate, baseAmountEur);
this.logger.log(
`Commission applied: ${commissionRate}% on ${baseAmountEur}€ = ${booking.commissionAmountEur}`
);
} catch (error: any) {
this.logger.error(`Failed to apply commission: ${error?.message}`, error?.stack);
}
// Save updated booking // Save updated booking
const updatedBooking = await this.csvBookingRepository.update(booking); const updatedBooking = await this.csvBookingRepository.update(booking);
this.logger.log(`Booking ${booking.id} accepted`); this.logger.log(`Booking ${booking.id} accepted`);
@ -902,7 +568,6 @@ export class CsvBookingService {
const stats = await this.csvBookingRepository.countByStatusForUser(userId); const stats = await this.csvBookingRepository.countByStatusForUser(userId);
return { return {
pendingPayment: stats[CsvBookingStatus.PENDING_PAYMENT] || 0,
pending: stats[CsvBookingStatus.PENDING] || 0, pending: stats[CsvBookingStatus.PENDING] || 0,
accepted: stats[CsvBookingStatus.ACCEPTED] || 0, accepted: stats[CsvBookingStatus.ACCEPTED] || 0,
rejected: stats[CsvBookingStatus.REJECTED] || 0, rejected: stats[CsvBookingStatus.REJECTED] || 0,
@ -918,7 +583,6 @@ export class CsvBookingService {
const stats = await this.csvBookingRepository.countByStatusForOrganization(organizationId); const stats = await this.csvBookingRepository.countByStatusForOrganization(organizationId);
return { return {
pendingPayment: stats[CsvBookingStatus.PENDING_PAYMENT] || 0,
pending: stats[CsvBookingStatus.PENDING] || 0, pending: stats[CsvBookingStatus.PENDING] || 0,
accepted: stats[CsvBookingStatus.ACCEPTED] || 0, accepted: stats[CsvBookingStatus.ACCEPTED] || 0,
rejected: stats[CsvBookingStatus.REJECTED] || 0, rejected: stats[CsvBookingStatus.REJECTED] || 0,
@ -1014,15 +678,9 @@ export class CsvBookingService {
throw new NotFoundException(`Booking with ID ${bookingId} not found`); throw new NotFoundException(`Booking with ID ${bookingId} not found`);
} }
// Allow adding documents to PENDING_PAYMENT, PENDING, or ACCEPTED bookings // Allow adding documents to PENDING or ACCEPTED bookings
if ( if (booking.status !== CsvBookingStatus.PENDING && booking.status !== CsvBookingStatus.ACCEPTED) {
booking.status !== CsvBookingStatus.PENDING_PAYMENT && throw new BadRequestException('Cannot add documents to a booking that is rejected or cancelled');
booking.status !== CsvBookingStatus.PENDING &&
booking.status !== CsvBookingStatus.ACCEPTED
) {
throw new BadRequestException(
'Cannot add documents to a booking that is rejected or cancelled'
);
} }
// Upload new documents // Upload new documents
@ -1065,10 +723,7 @@ export class CsvBookingService {
}); });
this.logger.log(`New documents notification sent to carrier: ${booking.carrierEmail}`); this.logger.log(`New documents notification sent to carrier: ${booking.carrierEmail}`);
} catch (error: any) { } catch (error: any) {
this.logger.error( this.logger.error(`Failed to send new documents notification: ${error?.message}`, error?.stack);
`Failed to send new documents notification: ${error?.message}`,
error?.stack
);
} }
} }
@ -1100,11 +755,8 @@ export class CsvBookingService {
throw new NotFoundException(`Booking with ID ${bookingId} not found`); throw new NotFoundException(`Booking with ID ${bookingId} not found`);
} }
// Verify booking is still pending or awaiting payment // Verify booking is still pending
if ( if (booking.status !== CsvBookingStatus.PENDING) {
booking.status !== CsvBookingStatus.PENDING_PAYMENT &&
booking.status !== CsvBookingStatus.PENDING
) {
throw new BadRequestException('Cannot delete documents from a booking that is not pending'); throw new BadRequestException('Cannot delete documents from a booking that is not pending');
} }
@ -1219,9 +871,7 @@ export class CsvBookingService {
await this.csvBookingRepository['repository'].save(ormBooking); await this.csvBookingRepository['repository'].save(ormBooking);
} }
this.logger.log( this.logger.log(`Replaced document ${documentId} with ${newDocument.id} in booking ${bookingId}`);
`Replaced document ${documentId} with ${newDocument.id} in booking ${bookingId}`
);
return { return {
success: true, success: true,
@ -1297,8 +947,6 @@ export class CsvBookingService {
routeDescription: booking.getRouteDescription(), routeDescription: booking.getRouteDescription(),
isExpired: booking.isExpired(), isExpired: booking.isExpired(),
price: booking.getPriceInCurrency(primaryCurrency), price: booking.getPriceInCurrency(primaryCurrency),
commissionRate: booking.commissionRate,
commissionAmountEur: booking.commissionAmountEur,
}; };
} }

View File

@ -120,7 +120,10 @@ export class GDPRService {
/** /**
* Record or update consent (GDPR Article 7 - Conditions for consent) * Record or update consent (GDPR Article 7 - Conditions for consent)
*/ */
async recordConsent(userId: string, consentData: UpdateConsentDto): Promise<ConsentResponseDto> { async recordConsent(
userId: string,
consentData: UpdateConsentDto
): Promise<ConsentResponseDto> {
this.logger.log(`Recording consent for user ${userId}`); this.logger.log(`Recording consent for user ${userId}`);
// Verify user exists // Verify user exists

View File

@ -38,7 +38,7 @@ export class InvitationService {
@Inject(EMAIL_PORT) @Inject(EMAIL_PORT)
private readonly emailService: EmailPort, private readonly emailService: EmailPort,
private readonly configService: ConfigService, private readonly configService: ConfigService,
private readonly subscriptionService: SubscriptionService private readonly subscriptionService: SubscriptionService,
) {} ) {}
/** /**
@ -50,8 +50,7 @@ export class InvitationService {
lastName: string, lastName: string,
role: UserRole, role: UserRole,
organizationId: string, organizationId: string,
invitedById: string, invitedById: string
inviterRole?: string
): Promise<InvitationToken> { ): Promise<InvitationToken> {
this.logger.log(`Creating invitation for ${email} in organization ${organizationId}`); this.logger.log(`Creating invitation for ${email} in organization ${organizationId}`);
@ -70,14 +69,14 @@ export class InvitationService {
} }
// Check if licenses are available for this organization // Check if licenses are available for this organization
const canInviteResult = await this.subscriptionService.canInviteUser(organizationId, inviterRole); const canInviteResult = await this.subscriptionService.canInviteUser(organizationId);
if (!canInviteResult.canInvite) { if (!canInviteResult.canInvite) {
this.logger.warn( this.logger.warn(
`License limit reached for organization ${organizationId}: ${canInviteResult.usedLicenses}/${canInviteResult.maxLicenses}` `License limit reached for organization ${organizationId}: ${canInviteResult.usedLicenses}/${canInviteResult.maxLicenses}`,
); );
throw new ForbiddenException( throw new ForbiddenException(
canInviteResult.message || canInviteResult.message ||
`License limit reached. Please upgrade your subscription to invite more users.` `License limit reached. Please upgrade your subscription to invite more users.`,
); );
} }

View File

@ -4,14 +4,24 @@
* Business logic for subscription and license management. * Business logic for subscription and license management.
*/ */
import { Injectable, Inject, Logger, NotFoundException, BadRequestException } from '@nestjs/common'; import {
Injectable,
Inject,
Logger,
NotFoundException,
BadRequestException,
ForbiddenException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { import {
SubscriptionRepository, SubscriptionRepository,
SUBSCRIPTION_REPOSITORY, SUBSCRIPTION_REPOSITORY,
} from '@domain/ports/out/subscription.repository'; } from '@domain/ports/out/subscription.repository';
import { LicenseRepository, LICENSE_REPOSITORY } from '@domain/ports/out/license.repository'; import {
LicenseRepository,
LICENSE_REPOSITORY,
} from '@domain/ports/out/license.repository';
import { import {
OrganizationRepository, OrganizationRepository,
ORGANIZATION_REPOSITORY, ORGANIZATION_REPOSITORY,
@ -20,10 +30,14 @@ import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.reposito
import { StripePort, STRIPE_PORT } from '@domain/ports/out/stripe.port'; import { StripePort, STRIPE_PORT } from '@domain/ports/out/stripe.port';
import { Subscription } from '@domain/entities/subscription.entity'; import { Subscription } from '@domain/entities/subscription.entity';
import { License } from '@domain/entities/license.entity'; import { License } from '@domain/entities/license.entity';
import { SubscriptionPlan, SubscriptionPlanType } from '@domain/value-objects/subscription-plan.vo'; import {
SubscriptionPlan,
SubscriptionPlanType,
} from '@domain/value-objects/subscription-plan.vo';
import { SubscriptionStatus } from '@domain/value-objects/subscription-status.vo'; import { SubscriptionStatus } from '@domain/value-objects/subscription-status.vo';
import { import {
NoLicensesAvailableException, NoLicensesAvailableException,
SubscriptionNotFoundException,
LicenseAlreadyAssignedException, LicenseAlreadyAssignedException,
} from '@domain/exceptions/subscription.exceptions'; } from '@domain/exceptions/subscription.exceptions';
import { import {
@ -55,54 +69,50 @@ export class SubscriptionService {
private readonly userRepository: UserRepository, private readonly userRepository: UserRepository,
@Inject(STRIPE_PORT) @Inject(STRIPE_PORT)
private readonly stripeAdapter: StripePort, private readonly stripeAdapter: StripePort,
private readonly configService: ConfigService private readonly configService: ConfigService,
) {} ) {}
/** /**
* Get subscription overview for an organization * Get subscription overview for an organization
* ADMIN users always see a PLATINIUM plan with no expiration
*/ */
async getSubscriptionOverview( async getSubscriptionOverview(
organizationId: string, organizationId: string,
userRole?: string
): Promise<SubscriptionOverviewResponseDto> { ): Promise<SubscriptionOverviewResponseDto> {
const subscription = await this.getOrCreateSubscription(organizationId); const subscription = await this.getOrCreateSubscription(organizationId);
const activeLicenses = await this.licenseRepository.findActiveBySubscriptionId(subscription.id); const activeLicenses = await this.licenseRepository.findActiveBySubscriptionId(
subscription.id,
);
// Enrich licenses with user information // Enrich licenses with user information
const enrichedLicenses = await Promise.all( const enrichedLicenses = await Promise.all(
activeLicenses.map(async license => { activeLicenses.map(async (license) => {
const user = await this.userRepository.findById(license.userId); const user = await this.userRepository.findById(license.userId);
return this.mapLicenseToDto(license, user); return this.mapLicenseToDto(license, user);
}) }),
); );
// Count only non-ADMIN licenses for quota calculation // Count only non-ADMIN licenses for quota calculation
// ADMIN users have unlimited licenses and don't count against the quota // ADMIN users have unlimited licenses and don't count against the quota
const usedLicenses = await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins( const usedLicenses = await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins(
subscription.id subscription.id,
); );
const maxLicenses = subscription.maxLicenses;
// ADMIN users always have PLATINIUM plan with no expiration const availableLicenses = subscription.isUnlimited()
const isAdmin = userRole === 'ADMIN';
const effectivePlan = isAdmin ? SubscriptionPlan.platinium() : subscription.plan;
const maxLicenses = effectivePlan.maxLicenses;
const availableLicenses = effectivePlan.isUnlimited()
? -1 ? -1
: Math.max(0, maxLicenses - usedLicenses); : Math.max(0, maxLicenses - usedLicenses);
return { return {
id: subscription.id, id: subscription.id,
organizationId: subscription.organizationId, organizationId: subscription.organizationId,
plan: effectivePlan.value as SubscriptionPlanDto, plan: subscription.plan.value as SubscriptionPlanDto,
planDetails: this.mapPlanToDto(effectivePlan), planDetails: this.mapPlanToDto(subscription.plan),
status: subscription.status.value as SubscriptionStatusDto, status: subscription.status.value as SubscriptionStatusDto,
usedLicenses, usedLicenses,
maxLicenses, maxLicenses,
availableLicenses, availableLicenses,
cancelAtPeriodEnd: false, cancelAtPeriodEnd: subscription.cancelAtPeriodEnd,
currentPeriodStart: isAdmin ? undefined : subscription.currentPeriodStart || undefined, currentPeriodStart: subscription.currentPeriodStart || undefined,
currentPeriodEnd: isAdmin ? undefined : subscription.currentPeriodEnd || undefined, currentPeriodEnd: subscription.currentPeriodEnd || undefined,
createdAt: subscription.createdAt, createdAt: subscription.createdAt,
updatedAt: subscription.updatedAt, updatedAt: subscription.updatedAt,
licenses: enrichedLicenses, licenses: enrichedLicenses,
@ -113,35 +123,27 @@ export class SubscriptionService {
* Get all available plans * Get all available plans
*/ */
getAllPlans(): AllPlansResponseDto { getAllPlans(): AllPlansResponseDto {
const plans = SubscriptionPlan.getAllPlans().map(plan => this.mapPlanToDto(plan)); const plans = SubscriptionPlan.getAllPlans().map((plan) =>
this.mapPlanToDto(plan),
);
return { plans }; return { plans };
} }
/** /**
* Check if organization can invite more users * Check if organization can invite more users
* Note: ADMIN users don't count against the license quota and always have unlimited licenses * Note: ADMIN users don't count against the license quota
*/ */
async canInviteUser(organizationId: string, userRole?: string): Promise<CanInviteResponseDto> { async canInviteUser(organizationId: string): Promise<CanInviteResponseDto> {
// 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); const subscription = await this.getOrCreateSubscription(organizationId);
// Count only non-ADMIN licenses - ADMIN users have unlimited licenses // Count only non-ADMIN licenses - ADMIN users have unlimited licenses
const usedLicenses = await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins( const usedLicenses = await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins(
subscription.id subscription.id,
); );
const maxLicenses = subscription.maxLicenses; const maxLicenses = subscription.maxLicenses;
const canInvite = const canInvite =
subscription.isActive() && (subscription.isUnlimited() || usedLicenses < maxLicenses); subscription.isActive() &&
(subscription.isUnlimited() || usedLicenses < maxLicenses);
const availableLicenses = subscription.isUnlimited() const availableLicenses = subscription.isUnlimited()
? -1 ? -1
@ -169,7 +171,7 @@ export class SubscriptionService {
async createCheckoutSession( async createCheckoutSession(
organizationId: string, organizationId: string,
userId: string, userId: string,
dto: CreateCheckoutSessionDto dto: CreateCheckoutSessionDto,
): Promise<CheckoutSessionResponseDto> { ): Promise<CheckoutSessionResponseDto> {
const organization = await this.organizationRepository.findById(organizationId); const organization = await this.organizationRepository.findById(organizationId);
if (!organization) { if (!organization) {
@ -182,19 +184,23 @@ export class SubscriptionService {
} }
// Cannot checkout for FREE plan // Cannot checkout for FREE plan
if (dto.plan === SubscriptionPlanDto.BRONZE) { if (dto.plan === SubscriptionPlanDto.FREE) {
throw new BadRequestException('Cannot create checkout session for Bronze plan'); throw new BadRequestException('Cannot create checkout session for FREE plan');
} }
const subscription = await this.getOrCreateSubscription(organizationId); const subscription = await this.getOrCreateSubscription(organizationId);
const frontendUrl = this.configService.get<string>('FRONTEND_URL', 'http://localhost:3000'); const frontendUrl = this.configService.get<string>(
'FRONTEND_URL',
'http://localhost:3000',
);
// Include {CHECKOUT_SESSION_ID} placeholder - Stripe replaces it with actual session ID // Include {CHECKOUT_SESSION_ID} placeholder - Stripe replaces it with actual session ID
const successUrl = const successUrl =
dto.successUrl || dto.successUrl ||
`${frontendUrl}/dashboard/settings/organization?success=true&session_id={CHECKOUT_SESSION_ID}`; `${frontendUrl}/dashboard/settings/organization?success=true&session_id={CHECKOUT_SESSION_ID}`;
const cancelUrl = const cancelUrl =
dto.cancelUrl || `${frontendUrl}/dashboard/settings/organization?canceled=true`; dto.cancelUrl ||
`${frontendUrl}/dashboard/settings/organization?canceled=true`;
const result = await this.stripeAdapter.createCheckoutSession({ const result = await this.stripeAdapter.createCheckoutSession({
organizationId, organizationId,
@ -208,7 +214,7 @@ export class SubscriptionService {
}); });
this.logger.log( this.logger.log(
`Created checkout session for organization ${organizationId}, plan ${dto.plan}` `Created checkout session for organization ${organizationId}, plan ${dto.plan}`,
); );
return { return {
@ -222,18 +228,24 @@ export class SubscriptionService {
*/ */
async createPortalSession( async createPortalSession(
organizationId: string, organizationId: string,
dto: CreatePortalSessionDto dto: CreatePortalSessionDto,
): Promise<PortalSessionResponseDto> { ): Promise<PortalSessionResponseDto> {
const subscription = await this.subscriptionRepository.findByOrganizationId(organizationId); const subscription = await this.subscriptionRepository.findByOrganizationId(
organizationId,
);
if (!subscription?.stripeCustomerId) { if (!subscription?.stripeCustomerId) {
throw new BadRequestException( throw new BadRequestException(
'No Stripe customer found for this organization. Please complete a checkout first.' 'No Stripe customer found for this organization. Please complete a checkout first.',
); );
} }
const frontendUrl = this.configService.get<string>('FRONTEND_URL', 'http://localhost:3000'); const frontendUrl = this.configService.get<string>(
const returnUrl = dto.returnUrl || `${frontendUrl}/dashboard/settings/organization`; 'FRONTEND_URL',
'http://localhost:3000',
);
const returnUrl =
dto.returnUrl || `${frontendUrl}/dashboard/settings/organization`;
const result = await this.stripeAdapter.createPortalSession({ const result = await this.stripeAdapter.createPortalSession({
customerId: subscription.stripeCustomerId, customerId: subscription.stripeCustomerId,
@ -255,9 +267,11 @@ export class SubscriptionService {
*/ */
async syncFromStripe( async syncFromStripe(
organizationId: string, organizationId: string,
sessionId?: string sessionId?: string,
): Promise<SubscriptionOverviewResponseDto> { ): Promise<SubscriptionOverviewResponseDto> {
let subscription = await this.subscriptionRepository.findByOrganizationId(organizationId); let subscription = await this.subscriptionRepository.findByOrganizationId(
organizationId,
);
if (!subscription) { if (!subscription) {
subscription = await this.getOrCreateSubscription(organizationId); subscription = await this.getOrCreateSubscription(organizationId);
@ -269,14 +283,12 @@ export class SubscriptionService {
// If we have a session ID, ALWAYS retrieve the checkout session to get the latest subscription details // 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 // This is important for upgrades where Stripe may create a new subscription
if (sessionId) { if (sessionId) {
this.logger.log( this.logger.log(`Retrieving checkout session ${sessionId} for organization ${organizationId}`);
`Retrieving checkout session ${sessionId} for organization ${organizationId}`
);
const checkoutSession = await this.stripeAdapter.getCheckoutSession(sessionId); const checkoutSession = await this.stripeAdapter.getCheckoutSession(sessionId);
if (checkoutSession) { if (checkoutSession) {
this.logger.log( this.logger.log(
`Checkout session found: subscriptionId=${checkoutSession.subscriptionId}, customerId=${checkoutSession.customerId}, status=${checkoutSession.status}` `Checkout session found: subscriptionId=${checkoutSession.subscriptionId}, customerId=${checkoutSession.customerId}, status=${checkoutSession.status}`,
); );
// Always use the subscription ID from the checkout session if available // Always use the subscription ID from the checkout session if available
@ -318,7 +330,7 @@ export class SubscriptionService {
if (plan) { if (plan) {
// Count only non-ADMIN licenses - ADMIN users have unlimited licenses // Count only non-ADMIN licenses - ADMIN users have unlimited licenses
const usedLicenses = await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins( const usedLicenses = await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins(
subscription.id subscription.id,
); );
const newPlan = SubscriptionPlan.create(plan); const newPlan = SubscriptionPlan.create(plan);
@ -342,13 +354,13 @@ export class SubscriptionService {
// Update status // Update status
updatedSubscription = updatedSubscription.updateStatus( updatedSubscription = updatedSubscription.updateStatus(
SubscriptionStatus.fromStripeStatus(stripeData.status) SubscriptionStatus.fromStripeStatus(stripeData.status),
); );
await this.subscriptionRepository.save(updatedSubscription); await this.subscriptionRepository.save(updatedSubscription);
this.logger.log( this.logger.log(
`Synced subscription for organization ${organizationId} from Stripe (plan: ${updatedSubscription.plan.value})` `Synced subscription for organization ${organizationId} from Stripe (plan: ${updatedSubscription.plan.value})`,
); );
return this.getSubscriptionOverview(organizationId); return this.getSubscriptionOverview(organizationId);
@ -406,14 +418,14 @@ export class SubscriptionService {
if (!isAdmin) { if (!isAdmin) {
// Count only non-ADMIN licenses for quota check // Count only non-ADMIN licenses for quota check
const usedLicenses = await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins( const usedLicenses = await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins(
subscription.id subscription.id,
); );
if (!subscription.canAllocateLicenses(usedLicenses)) { if (!subscription.canAllocateLicenses(usedLicenses)) {
throw new NoLicensesAvailableException( throw new NoLicensesAvailableException(
organizationId, organizationId,
usedLicenses, usedLicenses,
subscription.maxLicenses subscription.maxLicenses,
); );
} }
} }
@ -462,18 +474,22 @@ export class SubscriptionService {
* Get or create a subscription for an organization * Get or create a subscription for an organization
*/ */
async getOrCreateSubscription(organizationId: string): Promise<Subscription> { async getOrCreateSubscription(organizationId: string): Promise<Subscription> {
let subscription = await this.subscriptionRepository.findByOrganizationId(organizationId); let subscription = await this.subscriptionRepository.findByOrganizationId(
organizationId,
);
if (!subscription) { if (!subscription) {
// Create FREE subscription for the organization // Create FREE subscription for the organization
subscription = Subscription.create({ subscription = Subscription.create({
id: uuidv4(), id: uuidv4(),
organizationId, organizationId,
plan: SubscriptionPlan.bronze(), plan: SubscriptionPlan.free(),
}); });
subscription = await this.subscriptionRepository.save(subscription); subscription = await this.subscriptionRepository.save(subscription);
this.logger.log(`Created Bronze subscription for organization ${organizationId}`); this.logger.log(
`Created FREE subscription for organization ${organizationId}`,
);
} }
return subscription; return subscription;
@ -481,7 +497,9 @@ export class SubscriptionService {
// Private helper methods // Private helper methods
private async handleCheckoutCompleted(session: Record<string, unknown>): Promise<void> { private async handleCheckoutCompleted(
session: Record<string, unknown>,
): Promise<void> {
const metadata = session.metadata as Record<string, string> | undefined; const metadata = session.metadata as Record<string, string> | undefined;
const organizationId = metadata?.organizationId; const organizationId = metadata?.organizationId;
const customerId = session.customer as string; const customerId = session.customer as string;
@ -519,26 +537,27 @@ export class SubscriptionService {
}); });
subscription = subscription.updatePlan( subscription = subscription.updatePlan(
SubscriptionPlan.create(plan), SubscriptionPlan.create(plan),
await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins(subscription.id) await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins(subscription.id),
); );
subscription = subscription.updateStatus( subscription = subscription.updateStatus(
SubscriptionStatus.fromStripeStatus(stripeSubscription.status) SubscriptionStatus.fromStripeStatus(stripeSubscription.status),
); );
await this.subscriptionRepository.save(subscription); await this.subscriptionRepository.save(subscription);
// Update organization status badge to match the plan this.logger.log(
await this.updateOrganizationBadge(organizationId, subscription.statusBadge); `Updated subscription for organization ${organizationId} to plan ${plan}`,
);
this.logger.log(`Updated subscription for organization ${organizationId} to plan ${plan}`);
} }
private async handleSubscriptionUpdated( private async handleSubscriptionUpdated(
stripeSubscription: Record<string, unknown> stripeSubscription: Record<string, unknown>,
): Promise<void> { ): Promise<void> {
const subscriptionId = stripeSubscription.id as string; const subscriptionId = stripeSubscription.id as string;
let subscription = await this.subscriptionRepository.findByStripeSubscriptionId(subscriptionId); let subscription = await this.subscriptionRepository.findByStripeSubscriptionId(
subscriptionId,
);
if (!subscription) { if (!subscription) {
this.logger.warn(`Subscription ${subscriptionId} not found in database`); this.logger.warn(`Subscription ${subscriptionId} not found in database`);
@ -557,7 +576,7 @@ export class SubscriptionService {
if (plan) { if (plan) {
// Count only non-ADMIN licenses - ADMIN users have unlimited licenses // Count only non-ADMIN licenses - ADMIN users have unlimited licenses
const usedLicenses = await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins( const usedLicenses = await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins(
subscription.id subscription.id,
); );
const newPlan = SubscriptionPlan.create(plan); const newPlan = SubscriptionPlan.create(plan);
@ -565,7 +584,9 @@ export class SubscriptionService {
if (newPlan.canAccommodateUsers(usedLicenses)) { if (newPlan.canAccommodateUsers(usedLicenses)) {
subscription = subscription.updatePlan(newPlan, usedLicenses); subscription = subscription.updatePlan(newPlan, usedLicenses);
} else { } else {
this.logger.warn(`Cannot update to plan ${plan} - would exceed license limit`); this.logger.warn(
`Cannot update to plan ${plan} - would exceed license limit`,
);
} }
} }
@ -576,26 +597,22 @@ export class SubscriptionService {
cancelAtPeriodEnd: stripeData.cancelAtPeriodEnd, cancelAtPeriodEnd: stripeData.cancelAtPeriodEnd,
}); });
subscription = subscription.updateStatus( subscription = subscription.updateStatus(
SubscriptionStatus.fromStripeStatus(stripeData.status) SubscriptionStatus.fromStripeStatus(stripeData.status),
); );
await this.subscriptionRepository.save(subscription); await this.subscriptionRepository.save(subscription);
// Update organization status badge to match the plan
if (subscription.organizationId) {
await this.updateOrganizationBadge(subscription.organizationId, subscription.statusBadge);
}
this.logger.log(`Updated subscription ${subscriptionId}`); this.logger.log(`Updated subscription ${subscriptionId}`);
} }
private async handleSubscriptionDeleted( private async handleSubscriptionDeleted(
stripeSubscription: Record<string, unknown> stripeSubscription: Record<string, unknown>,
): Promise<void> { ): Promise<void> {
const subscriptionId = stripeSubscription.id as string; const subscriptionId = stripeSubscription.id as string;
const subscription = const subscription = await this.subscriptionRepository.findByStripeSubscriptionId(
await this.subscriptionRepository.findByStripeSubscriptionId(subscriptionId); subscriptionId,
);
if (!subscription) { if (!subscription) {
this.logger.warn(`Subscription ${subscriptionId} not found in database`); this.logger.warn(`Subscription ${subscriptionId} not found in database`);
@ -605,41 +622,42 @@ export class SubscriptionService {
// Downgrade to FREE plan - count only non-ADMIN licenses // Downgrade to FREE plan - count only non-ADMIN licenses
const canceledSubscription = subscription const canceledSubscription = subscription
.updatePlan( .updatePlan(
SubscriptionPlan.bronze(), SubscriptionPlan.free(),
await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins(subscription.id) await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins(subscription.id),
) )
.updateStatus(SubscriptionStatus.canceled()); .updateStatus(SubscriptionStatus.canceled());
await this.subscriptionRepository.save(canceledSubscription); await this.subscriptionRepository.save(canceledSubscription);
// Reset organization badge to 'none' on cancellation this.logger.log(`Subscription ${subscriptionId} canceled, downgraded to FREE`);
if (subscription.organizationId) {
await this.updateOrganizationBadge(subscription.organizationId, 'none');
}
this.logger.log(`Subscription ${subscriptionId} canceled, downgraded to Bronze`);
} }
private async handlePaymentFailed(invoice: Record<string, unknown>): Promise<void> { private async handlePaymentFailed(invoice: Record<string, unknown>): Promise<void> {
const customerId = invoice.customer as string; const customerId = invoice.customer as string;
const subscription = await this.subscriptionRepository.findByStripeCustomerId(customerId); const subscription = await this.subscriptionRepository.findByStripeCustomerId(
customerId,
);
if (!subscription) { if (!subscription) {
this.logger.warn(`Subscription for customer ${customerId} not found`); this.logger.warn(`Subscription for customer ${customerId} not found`);
return; return;
} }
const updatedSubscription = subscription.updateStatus(SubscriptionStatus.pastDue()); const updatedSubscription = subscription.updateStatus(
SubscriptionStatus.pastDue(),
);
await this.subscriptionRepository.save(updatedSubscription); await this.subscriptionRepository.save(updatedSubscription);
this.logger.log(`Subscription ${subscription.id} marked as past due due to payment failure`); this.logger.log(
`Subscription ${subscription.id} marked as past due due to payment failure`,
);
} }
private mapLicenseToDto( private mapLicenseToDto(
license: License, license: License,
user: { email: string; firstName: string; lastName: string; role: string } | null user: { email: string; firstName: string; lastName: string; role: string } | null,
): LicenseResponseDto { ): LicenseResponseDto {
return { return {
id: license.id, id: license.id,
@ -653,19 +671,6 @@ export class SubscriptionService {
}; };
} }
private async updateOrganizationBadge(organizationId: string, badge: string): Promise<void> {
try {
const organization = await this.organizationRepository.findById(organizationId);
if (organization) {
organization.updateStatusBadge(badge as 'none' | 'silver' | 'gold' | 'platinium');
await this.organizationRepository.save(organization);
this.logger.log(`Updated status badge for organization ${organizationId} to ${badge}`);
}
} catch (error: any) {
this.logger.error(`Failed to update organization badge: ${error?.message}`, error?.stack);
}
}
private mapPlanToDto(plan: SubscriptionPlan): PlanDetailsDto { private mapPlanToDto(plan: SubscriptionPlan): PlanDetailsDto {
return { return {
plan: plan.value as SubscriptionPlanDto, plan: plan.value as SubscriptionPlanDto,
@ -673,11 +678,6 @@ export class SubscriptionService {
maxLicenses: plan.maxLicenses, maxLicenses: plan.maxLicenses,
monthlyPriceEur: plan.monthlyPriceEur, monthlyPriceEur: plan.monthlyPriceEur,
yearlyPriceEur: plan.yearlyPriceEur, yearlyPriceEur: plan.yearlyPriceEur,
maxShipmentsPerYear: plan.maxShipmentsPerYear,
commissionRatePercent: plan.commissionRatePercent,
supportLevel: plan.supportLevel,
statusBadge: plan.statusBadge,
planFeatures: [...plan.planFeatures],
features: [...plan.features], features: [...plan.features],
}; };
} }

View File

@ -7,13 +7,14 @@ import { USER_REPOSITORY } from '@domain/ports/out/user.repository';
import { TypeOrmUserRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-user.repository'; import { TypeOrmUserRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-user.repository';
import { UserOrmEntity } from '../../infrastructure/persistence/typeorm/entities/user.orm-entity'; import { UserOrmEntity } from '../../infrastructure/persistence/typeorm/entities/user.orm-entity';
import { SubscriptionsModule } from '../subscriptions/subscriptions.module'; import { SubscriptionsModule } from '../subscriptions/subscriptions.module';
import { FeatureFlagGuard } from '../guards/feature-flag.guard';
@Module({ @Module({
imports: [TypeOrmModule.forFeature([UserOrmEntity]), SubscriptionsModule], imports: [
TypeOrmModule.forFeature([UserOrmEntity]),
SubscriptionsModule,
],
controllers: [UsersController], controllers: [UsersController],
providers: [ providers: [
FeatureFlagGuard,
{ {
provide: USER_REPOSITORY, provide: USER_REPOSITORY,
useClass: TypeOrmUserRepository, useClass: TypeOrmUserRepository,

View File

@ -50,8 +50,6 @@ export interface BookingProps {
cargoDescription: string; cargoDescription: string;
containers: BookingContainer[]; containers: BookingContainer[];
specialInstructions?: string; specialInstructions?: string;
commissionRate?: number;
commissionAmountEur?: number;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
} }
@ -163,14 +161,6 @@ export class Booking {
return this.props.specialInstructions; return this.props.specialInstructions;
} }
get commissionRate(): number | undefined {
return this.props.commissionRate;
}
get commissionAmountEur(): number | undefined {
return this.props.commissionAmountEur;
}
get createdAt(): Date { get createdAt(): Date {
return this.props.createdAt; return this.props.createdAt;
} }
@ -280,19 +270,6 @@ export class Booking {
}); });
} }
/**
* Apply commission to the booking
*/
applyCommission(ratePercent: number, baseAmountEur: number): Booking {
const commissionAmount = Math.round(baseAmountEur * ratePercent) / 100;
return new Booking({
...this.props,
commissionRate: ratePercent,
commissionAmountEur: commissionAmount,
updatedAt: new Date(),
});
}
/** /**
* Check if booking can be cancelled * Check if booking can be cancelled
*/ */

View File

@ -6,8 +6,6 @@ import { PortCode } from '../value-objects/port-code.vo';
* Represents the lifecycle of a CSV-based booking request * Represents the lifecycle of a CSV-based booking request
*/ */
export enum CsvBookingStatus { 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 PENDING = 'PENDING', // Awaiting carrier response
ACCEPTED = 'ACCEPTED', // Carrier accepted the booking ACCEPTED = 'ACCEPTED', // Carrier accepted the booking
REJECTED = 'REJECTED', // Carrier rejected the booking REJECTED = 'REJECTED', // Carrier rejected the booking
@ -82,10 +80,7 @@ export class CsvBooking {
public respondedAt?: Date, public respondedAt?: Date,
public notes?: string, public notes?: string,
public rejectionReason?: string, public rejectionReason?: string,
public readonly bookingNumber?: string, public readonly bookingNumber?: string
public commissionRate?: number,
public commissionAmountEur?: number,
public stripePaymentIntentId?: string
) { ) {
this.validate(); this.validate();
} }
@ -149,61 +144,6 @@ export class CsvBooking {
} }
} }
/**
* Apply commission to the booking
*/
applyCommission(ratePercent: number, baseAmountEur: number): void {
this.commissionRate = ratePercent;
this.commissionAmountEur = Math.round(baseAmountEur * ratePercent) / 100;
}
/**
* Mark commission payment as completed transition to PENDING
*
* @throws Error if booking is not in PENDING_PAYMENT status
*/
markPaymentCompleted(): void {
if (this.status !== CsvBookingStatus.PENDING_PAYMENT) {
throw new Error(
`Cannot mark payment completed for booking with status ${this.status}. Only PENDING_PAYMENT bookings can transition.`
);
}
this.status = CsvBookingStatus.PENDING;
}
/**
* 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 * Accept the booking
* *
@ -262,10 +202,6 @@ export class CsvBooking {
throw new Error('Cannot cancel rejected booking'); throw new Error('Cannot cancel rejected booking');
} }
if (this.status === CsvBookingStatus.CANCELLED) {
throw new Error('Booking is already cancelled');
}
this.status = CsvBookingStatus.CANCELLED; this.status = CsvBookingStatus.CANCELLED;
this.respondedAt = new Date(); this.respondedAt = new Date();
} }
@ -275,10 +211,6 @@ export class CsvBooking {
* *
* @returns true if booking is older than 7 days and still pending * @returns true if booking is older than 7 days and still pending
*/ */
isPendingPayment(): boolean {
return this.status === CsvBookingStatus.PENDING_PAYMENT;
}
isExpired(): boolean { isExpired(): boolean {
if (this.status !== CsvBookingStatus.PENDING) { if (this.status !== CsvBookingStatus.PENDING) {
return false; return false;
@ -431,10 +363,7 @@ export class CsvBooking {
respondedAt?: Date, respondedAt?: Date,
notes?: string, notes?: string,
rejectionReason?: string, rejectionReason?: string,
bookingNumber?: string, bookingNumber?: string
commissionRate?: number,
commissionAmountEur?: number,
stripePaymentIntentId?: string
): CsvBooking { ): CsvBooking {
// Create instance without calling constructor validation // Create instance without calling constructor validation
const booking = Object.create(CsvBooking.prototype); const booking = Object.create(CsvBooking.prototype);
@ -463,9 +392,6 @@ export class CsvBooking {
booking.notes = notes; booking.notes = notes;
booking.rejectionReason = rejectionReason; booking.rejectionReason = rejectionReason;
booking.bookingNumber = bookingNumber; booking.bookingNumber = bookingNumber;
booking.commissionRate = commissionRate;
booking.commissionAmountEur = commissionAmountEur;
booking.stripePaymentIntentId = stripePaymentIntentId;
return booking; return booking;
} }

View File

@ -5,7 +5,10 @@
* Each active user in an organization consumes one license. * Each active user in an organization consumes one license.
*/ */
import { LicenseStatus, LicenseStatusType } from '../value-objects/license-status.vo'; import {
LicenseStatus,
LicenseStatusType,
} from '../value-objects/license-status.vo';
export interface LicenseProps { export interface LicenseProps {
readonly id: string; readonly id: string;
@ -26,7 +29,11 @@ export class License {
/** /**
* Create a new license for a user * Create a new license for a user
*/ */
static create(props: { id: string; subscriptionId: string; userId: string }): License { static create(props: {
id: string;
subscriptionId: string;
userId: string;
}): License {
return new License({ return new License({
id: props.id, id: props.id,
subscriptionId: props.subscriptionId, subscriptionId: props.subscriptionId,

View File

@ -44,9 +44,6 @@ export interface OrganizationProps {
address: OrganizationAddress; address: OrganizationAddress;
logoUrl?: string; logoUrl?: string;
documents: OrganizationDocument[]; documents: OrganizationDocument[];
siret?: string;
siretVerified: boolean;
statusBadge: 'none' | 'silver' | 'gold' | 'platinium';
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
isActive: boolean; isActive: boolean;
@ -62,19 +59,9 @@ export class Organization {
/** /**
* Factory method to create a new Organization * Factory method to create a new Organization
*/ */
static create( static create(props: Omit<OrganizationProps, 'createdAt' | 'updatedAt'>): Organization {
props: Omit<OrganizationProps, 'createdAt' | 'updatedAt' | 'siretVerified' | 'statusBadge'> & {
siretVerified?: boolean;
statusBadge?: 'none' | 'silver' | 'gold' | 'platinium';
}
): Organization {
const now = new Date(); const now = new Date();
// Validate SIRET if provided
if (props.siret && !Organization.isValidSiret(props.siret)) {
throw new Error('Invalid SIRET format. Must be 14 digits.');
}
// Validate SCAC code if provided // Validate SCAC code if provided
if (props.scac && !Organization.isValidSCAC(props.scac)) { if (props.scac && !Organization.isValidSCAC(props.scac)) {
throw new Error('Invalid SCAC code format. Must be 4 uppercase letters.'); throw new Error('Invalid SCAC code format. Must be 4 uppercase letters.');
@ -92,8 +79,6 @@ export class Organization {
return new Organization({ return new Organization({
...props, ...props,
siretVerified: props.siretVerified ?? false,
statusBadge: props.statusBadge ?? 'none',
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}); });
@ -115,10 +100,6 @@ export class Organization {
return scacPattern.test(scac); return scacPattern.test(scac);
} }
private static isValidSiret(siret: string): boolean {
return /^\d{14}$/.test(siret);
}
// Getters // Getters
get id(): string { get id(): string {
return this.props.id; return this.props.id;
@ -172,18 +153,6 @@ export class Organization {
return this.props.updatedAt; return this.props.updatedAt;
} }
get siret(): string | undefined {
return this.props.siret;
}
get siretVerified(): boolean {
return this.props.siretVerified;
}
get statusBadge(): 'none' | 'silver' | 'gold' | 'platinium' {
return this.props.statusBadge;
}
get isActive(): boolean { get isActive(): boolean {
return this.props.isActive; return this.props.isActive;
} }
@ -214,25 +183,6 @@ export class Organization {
this.props.updatedAt = new Date(); this.props.updatedAt = new Date();
} }
updateSiret(siret: string): void {
if (!Organization.isValidSiret(siret)) {
throw new Error('Invalid SIRET format. Must be 14 digits.');
}
this.props.siret = siret;
this.props.siretVerified = false;
this.props.updatedAt = new Date();
}
markSiretVerified(): void {
this.props.siretVerified = true;
this.props.updatedAt = new Date();
}
updateStatusBadge(badge: 'none' | 'silver' | 'gold' | 'platinium'): void {
this.props.statusBadge = badge;
this.props.updatedAt = new Date();
}
updateSiren(siren: string): void { updateSiren(siren: string): void {
this.props.siren = siren; this.props.siren = siren;
this.props.updatedAt = new Date(); this.props.updatedAt = new Date();

View File

@ -272,7 +272,7 @@ describe('Subscription Entity', () => {
}); });
expect(() => subscription.updatePlan(SubscriptionPlan.starter(), 0)).toThrow( expect(() => subscription.updatePlan(SubscriptionPlan.starter(), 0)).toThrow(
SubscriptionNotActiveException SubscriptionNotActiveException,
); );
}); });
@ -284,7 +284,7 @@ describe('Subscription Entity', () => {
}); });
expect(() => subscription.updatePlan(SubscriptionPlan.starter(), 10)).toThrow( expect(() => subscription.updatePlan(SubscriptionPlan.starter(), 10)).toThrow(
InvalidSubscriptionDowngradeException InvalidSubscriptionDowngradeException,
); );
}); });
}); });

View File

@ -5,7 +5,10 @@
* Stripe integration, and billing period information. * Stripe integration, and billing period information.
*/ */
import { SubscriptionPlan, SubscriptionPlanType } from '../value-objects/subscription-plan.vo'; import {
SubscriptionPlan,
SubscriptionPlanType,
} from '../value-objects/subscription-plan.vo';
import { import {
SubscriptionStatus, SubscriptionStatus,
SubscriptionStatusType, SubscriptionStatusType,
@ -37,7 +40,7 @@ export class Subscription {
} }
/** /**
* Create a new subscription (defaults to Bronze/free plan) * Create a new subscription (defaults to FREE plan)
*/ */
static create(props: { static create(props: {
id: string; id: string;
@ -50,7 +53,7 @@ export class Subscription {
return new Subscription({ return new Subscription({
id: props.id, id: props.id,
organizationId: props.organizationId, organizationId: props.organizationId,
plan: props.plan ?? SubscriptionPlan.bronze(), plan: props.plan ?? SubscriptionPlan.free(),
status: SubscriptionStatus.active(), status: SubscriptionStatus.active(),
stripeCustomerId: props.stripeCustomerId ?? null, stripeCustomerId: props.stripeCustomerId ?? null,
stripeSubscriptionId: props.stripeSubscriptionId ?? null, stripeSubscriptionId: props.stripeSubscriptionId ?? null,
@ -65,41 +68,10 @@ export class Subscription {
/** /**
* Reconstitute from persistence * Reconstitute from persistence
*/ */
/**
* Check if a specific plan feature is available
*/
hasFeature(feature: import('../value-objects/plan-feature.vo').PlanFeature): boolean {
return this.props.plan.hasFeature(feature);
}
/**
* Get the maximum shipments per year allowed
*/
get maxShipmentsPerYear(): number {
return this.props.plan.maxShipmentsPerYear;
}
/**
* Get the commission rate for this subscription's plan
*/
get commissionRatePercent(): number {
return this.props.plan.commissionRatePercent;
}
/**
* Get the status badge for this subscription's plan
*/
get statusBadge(): string {
return this.props.plan.statusBadge;
}
/**
* Reconstitute from persistence (supports legacy plan names)
*/
static fromPersistence(props: { static fromPersistence(props: {
id: string; id: string;
organizationId: string; organizationId: string;
plan: string; // Accepts both old and new plan names plan: SubscriptionPlanType;
status: SubscriptionStatusType; status: SubscriptionStatusType;
stripeCustomerId: string | null; stripeCustomerId: string | null;
stripeSubscriptionId: string | null; stripeSubscriptionId: string | null;
@ -112,7 +84,7 @@ export class Subscription {
return new Subscription({ return new Subscription({
id: props.id, id: props.id,
organizationId: props.organizationId, organizationId: props.organizationId,
plan: SubscriptionPlan.fromString(props.plan), plan: SubscriptionPlan.create(props.plan),
status: SubscriptionStatus.create(props.status), status: SubscriptionStatus.create(props.status),
stripeCustomerId: props.stripeCustomerId, stripeCustomerId: props.stripeCustomerId,
stripeSubscriptionId: props.stripeSubscriptionId, stripeSubscriptionId: props.stripeSubscriptionId,
@ -264,7 +236,7 @@ export class Subscription {
this.props.plan.value, this.props.plan.value,
newPlan.value, newPlan.value,
currentUserCount, currentUserCount,
newPlan.maxLicenses newPlan.maxLicenses,
); );
} }

View File

@ -1,17 +0,0 @@
/**
* Shipment Limit Exceeded Exception
*
* Thrown when an organization has reached its annual shipment limit (Bronze plan).
*/
export class ShipmentLimitExceededException extends Error {
constructor(
public readonly organizationId: string,
public readonly currentCount: number,
public readonly maxCount: number
) {
super(
`L'organisation a atteint sa limite de ${maxCount} expéditions par an (${currentCount}/${maxCount}). Passez à un plan supérieur pour des expéditions illimitées.`
);
this.name = 'ShipmentLimitExceededException';
}
}

View File

@ -6,11 +6,11 @@ export class NoLicensesAvailableException extends Error {
constructor( constructor(
public readonly organizationId: string, public readonly organizationId: string,
public readonly currentLicenses: number, public readonly currentLicenses: number,
public readonly maxLicenses: number public readonly maxLicenses: number,
) { ) {
super( super(
`No licenses available for organization ${organizationId}. ` + `No licenses available for organization ${organizationId}. ` +
`Currently using ${currentLicenses}/${maxLicenses} licenses.` `Currently using ${currentLicenses}/${maxLicenses} licenses.`,
); );
this.name = 'NoLicensesAvailableException'; this.name = 'NoLicensesAvailableException';
Object.setPrototypeOf(this, NoLicensesAvailableException.prototype); Object.setPrototypeOf(this, NoLicensesAvailableException.prototype);
@ -46,11 +46,11 @@ export class InvalidSubscriptionDowngradeException extends Error {
public readonly currentPlan: string, public readonly currentPlan: string,
public readonly targetPlan: string, public readonly targetPlan: string,
public readonly currentUsers: number, public readonly currentUsers: number,
public readonly targetMaxLicenses: number public readonly targetMaxLicenses: number,
) { ) {
super( super(
`Cannot downgrade from ${currentPlan} to ${targetPlan}. ` + `Cannot downgrade from ${currentPlan} to ${targetPlan}. ` +
`Current users (${currentUsers}) exceed target plan limit (${targetMaxLicenses}).` `Current users (${currentUsers}) exceed target plan limit (${targetMaxLicenses}).`,
); );
this.name = 'InvalidSubscriptionDowngradeException'; this.name = 'InvalidSubscriptionDowngradeException';
Object.setPrototypeOf(this, InvalidSubscriptionDowngradeException.prototype); Object.setPrototypeOf(this, InvalidSubscriptionDowngradeException.prototype);
@ -60,9 +60,11 @@ export class InvalidSubscriptionDowngradeException extends Error {
export class SubscriptionNotActiveException extends Error { export class SubscriptionNotActiveException extends Error {
constructor( constructor(
public readonly subscriptionId: string, public readonly subscriptionId: string,
public readonly currentStatus: string public readonly currentStatus: string,
) { ) {
super(`Subscription ${subscriptionId} is not active. Current status: ${currentStatus}`); super(
`Subscription ${subscriptionId} is not active. Current status: ${currentStatus}`,
);
this.name = 'SubscriptionNotActiveException'; this.name = 'SubscriptionNotActiveException';
Object.setPrototypeOf(this, SubscriptionNotActiveException.prototype); Object.setPrototypeOf(this, SubscriptionNotActiveException.prototype);
} }
@ -71,10 +73,13 @@ export class SubscriptionNotActiveException extends Error {
export class InvalidSubscriptionStatusTransitionException extends Error { export class InvalidSubscriptionStatusTransitionException extends Error {
constructor( constructor(
public readonly fromStatus: string, public readonly fromStatus: string,
public readonly toStatus: string public readonly toStatus: string,
) { ) {
super(`Invalid subscription status transition from ${fromStatus} to ${toStatus}`); super(`Invalid subscription status transition from ${fromStatus} to ${toStatus}`);
this.name = 'InvalidSubscriptionStatusTransitionException'; this.name = 'InvalidSubscriptionStatusTransitionException';
Object.setPrototypeOf(this, InvalidSubscriptionStatusTransitionException.prototype); Object.setPrototypeOf(
this,
InvalidSubscriptionStatusTransitionException.prototype,
);
} }
} }

View File

@ -1,15 +0,0 @@
/**
* Shipment Counter Port
*
* Counts total shipments (bookings + CSV bookings) for an organization
* within a given year. Used to enforce the Bronze plan's 12 shipments/year limit.
*/
export const SHIPMENT_COUNTER_PORT = 'SHIPMENT_COUNTER_PORT';
export interface ShipmentCounterPort {
/**
* Count all shipments (bookings + CSV bookings) created by an organization in a given year.
*/
countShipmentsForOrganizationInYear(organizationId: string, year: number): Promise<number>;
}

View File

@ -1,11 +0,0 @@
export const SIRET_VERIFICATION_PORT = 'SIRET_VERIFICATION_PORT';
export interface SiretVerificationResult {
valid: boolean;
companyName?: string;
address?: string;
}
export interface SiretVerificationPort {
verify(siret: string): Promise<SiretVerificationResult>;
}

View File

@ -43,22 +43,6 @@ export interface StripeSubscriptionData {
cancelAtPeriodEnd: boolean; cancelAtPeriodEnd: boolean;
} }
export interface CreateCommissionCheckoutInput {
bookingId: string;
amountCents: number;
currency: 'eur';
customerEmail: string;
organizationId: string;
bookingDescription: string;
successUrl: string;
cancelUrl: string;
}
export interface CreateCommissionCheckoutOutput {
sessionId: string;
sessionUrl: string;
}
export interface StripeCheckoutSessionData { export interface StripeCheckoutSessionData {
sessionId: string; sessionId: string;
customerId: string | null; customerId: string | null;
@ -78,19 +62,16 @@ export interface StripePort {
/** /**
* Create a Stripe Checkout session for subscription purchase * Create a Stripe Checkout session for subscription purchase
*/ */
createCheckoutSession(input: CreateCheckoutSessionInput): Promise<CreateCheckoutSessionOutput>; createCheckoutSession(
input: CreateCheckoutSessionInput,
/** ): Promise<CreateCheckoutSessionOutput>;
* Create a Stripe Checkout session for one-time commission payment
*/
createCommissionCheckout(
input: CreateCommissionCheckoutInput
): Promise<CreateCommissionCheckoutOutput>;
/** /**
* Create a Stripe Customer Portal session for subscription management * Create a Stripe Customer Portal session for subscription management
*/ */
createPortalSession(input: CreatePortalSessionInput): Promise<CreatePortalSessionOutput>; createPortalSession(
input: CreatePortalSessionInput,
): Promise<CreatePortalSessionOutput>;
/** /**
* Retrieve subscription details from Stripe * Retrieve subscription details from Stripe
@ -120,7 +101,10 @@ export interface StripePort {
/** /**
* Verify and parse a Stripe webhook event * Verify and parse a Stripe webhook event
*/ */
constructWebhookEvent(payload: string | Buffer, signature: string): Promise<StripeWebhookEvent>; constructWebhookEvent(
payload: string | Buffer,
signature: string,
): Promise<StripeWebhookEvent>;
/** /**
* Map a Stripe price ID to a subscription plan * Map a Stripe price ID to a subscription plan

View File

@ -1,53 +0,0 @@
/**
* Plan Feature Value Object
*
* Defines the features available per subscription plan.
* Used by the FeatureFlagGuard to enforce access control.
*/
export type PlanFeature =
| 'dashboard'
| 'wiki'
| 'user_management'
| 'csv_export'
| 'api_access'
| 'custom_interface'
| 'dedicated_kam';
export const ALL_PLAN_FEATURES: readonly PlanFeature[] = [
'dashboard',
'wiki',
'user_management',
'csv_export',
'api_access',
'custom_interface',
'dedicated_kam',
];
export type SubscriptionPlanTypeForFeatures = 'BRONZE' | 'SILVER' | 'GOLD' | 'PLATINIUM';
export const PLAN_FEATURES: Record<SubscriptionPlanTypeForFeatures, readonly PlanFeature[]> = {
BRONZE: [],
SILVER: ['dashboard', 'wiki', 'user_management', 'csv_export'],
GOLD: ['dashboard', 'wiki', 'user_management', 'csv_export', 'api_access'],
PLATINIUM: [
'dashboard',
'wiki',
'user_management',
'csv_export',
'api_access',
'custom_interface',
'dedicated_kam',
],
};
export function planHasFeature(
plan: SubscriptionPlanTypeForFeatures,
feature: PlanFeature
): boolean {
return PLAN_FEATURES[plan].includes(feature);
}
export function planGetFeatures(plan: SubscriptionPlanTypeForFeatures): readonly PlanFeature[] {
return PLAN_FEATURES[plan];
}

View File

@ -2,109 +2,68 @@
* Subscription Plan Value Object * Subscription Plan Value Object
* *
* Represents the different subscription plans available for organizations. * Represents the different subscription plans available for organizations.
* Each plan has a maximum number of licenses, shipment limits, commission rates, * Each plan has a maximum number of licenses that determine how many users
* feature flags, and support levels. * can be active in an organization.
*
* Plans: BRONZE (free), SILVER (249EUR/mo), GOLD (899EUR/mo), PLATINIUM (custom)
*/ */
import { PlanFeature, PLAN_FEATURES } from './plan-feature.vo'; export type SubscriptionPlanType = 'FREE' | 'STARTER' | 'PRO' | 'ENTERPRISE';
export type SubscriptionPlanType = 'BRONZE' | 'SILVER' | 'GOLD' | 'PLATINIUM';
export type SupportLevel = 'none' | 'email' | 'direct' | 'dedicated_kam';
export type StatusBadge = 'none' | 'silver' | 'gold' | 'platinium';
/**
* Legacy plan name mapping for backward compatibility during migration.
*/
const LEGACY_PLAN_MAPPING: Record<string, SubscriptionPlanType> = {
FREE: 'BRONZE',
STARTER: 'SILVER',
PRO: 'GOLD',
ENTERPRISE: 'PLATINIUM',
};
interface PlanDetails { interface PlanDetails {
readonly name: string; readonly name: string;
readonly maxLicenses: number; // -1 means unlimited readonly maxLicenses: number; // -1 means unlimited
readonly monthlyPriceEur: number; readonly monthlyPriceEur: number;
readonly yearlyPriceEur: number; readonly yearlyPriceEur: number;
readonly maxShipmentsPerYear: number; // -1 means unlimited readonly features: readonly string[];
readonly commissionRatePercent: number;
readonly statusBadge: StatusBadge;
readonly supportLevel: SupportLevel;
readonly planFeatures: readonly PlanFeature[];
readonly features: readonly string[]; // Human-readable feature descriptions
} }
const PLAN_DETAILS: Record<SubscriptionPlanType, PlanDetails> = { const PLAN_DETAILS: Record<SubscriptionPlanType, PlanDetails> = {
BRONZE: { FREE: {
name: 'Bronze', name: 'Free',
maxLicenses: 1, maxLicenses: 2,
monthlyPriceEur: 0, monthlyPriceEur: 0,
yearlyPriceEur: 0, yearlyPriceEur: 0,
maxShipmentsPerYear: 12, features: [
commissionRatePercent: 5, 'Up to 2 users',
statusBadge: 'none', 'Basic rate search',
supportLevel: 'none', 'Email support',
planFeatures: PLAN_FEATURES.BRONZE, ],
features: ['1 utilisateur', '12 expéditions par an', 'Recherche de tarifs basique'],
}, },
SILVER: { STARTER: {
name: 'Silver', name: 'Starter',
maxLicenses: 5, maxLicenses: 5,
monthlyPriceEur: 249, monthlyPriceEur: 49,
yearlyPriceEur: 2739, // 249 * 11 months yearlyPriceEur: 470, // ~20% discount
maxShipmentsPerYear: -1,
commissionRatePercent: 3,
statusBadge: 'silver',
supportLevel: 'email',
planFeatures: PLAN_FEATURES.SILVER,
features: [ features: [
"Jusqu'à 5 utilisateurs", 'Up to 5 users',
'Expéditions illimitées', 'Advanced rate search',
'Tableau de bord', 'CSV imports',
'Wiki Maritime', 'Priority email support',
'Gestion des utilisateurs',
'Import CSV',
'Support par email',
], ],
}, },
GOLD: { PRO: {
name: 'Gold', name: 'Pro',
maxLicenses: 20, maxLicenses: 20,
monthlyPriceEur: 899, monthlyPriceEur: 149,
yearlyPriceEur: 9889, // 899 * 11 months yearlyPriceEur: 1430, // ~20% discount
maxShipmentsPerYear: -1,
commissionRatePercent: 2,
statusBadge: 'gold',
supportLevel: 'direct',
planFeatures: PLAN_FEATURES.GOLD,
features: [ features: [
"Jusqu'à 20 utilisateurs", 'Up to 20 users',
'Expéditions illimitées', 'All Starter features',
'Toutes les fonctionnalités Silver', 'API access',
'Intégration API', 'Custom integrations',
'Assistance commerciale directe', 'Phone support',
], ],
}, },
PLATINIUM: { ENTERPRISE: {
name: 'Platinium', name: 'Enterprise',
maxLicenses: -1, // unlimited maxLicenses: -1, // unlimited
monthlyPriceEur: 0, // custom pricing monthlyPriceEur: 0, // custom pricing
yearlyPriceEur: 0, // custom pricing yearlyPriceEur: 0, // custom pricing
maxShipmentsPerYear: -1,
commissionRatePercent: 1,
statusBadge: 'platinium',
supportLevel: 'dedicated_kam',
planFeatures: PLAN_FEATURES.PLATINIUM,
features: [ features: [
'Utilisateurs illimités', 'Unlimited users',
'Toutes les fonctionnalités Gold', 'All Pro features',
'Key Account Manager dédié', 'Dedicated account manager',
'Interface personnalisable', 'Custom SLA',
'Contrats tarifaires cadre', 'On-premise deployment option',
], ],
}, },
}; };
@ -119,68 +78,36 @@ export class SubscriptionPlan {
return new SubscriptionPlan(plan); return new SubscriptionPlan(plan);
} }
/**
* Create from string with legacy name support.
* Accepts both old (FREE/STARTER/PRO/ENTERPRISE) and new (BRONZE/SILVER/GOLD/PLATINIUM) names.
*/
static fromString(value: string): SubscriptionPlan { static fromString(value: string): SubscriptionPlan {
const upperValue = value.toUpperCase(); const upperValue = value.toUpperCase() as SubscriptionPlanType;
if (!PLAN_DETAILS[upperValue]) {
// Check legacy mapping first throw new Error(`Invalid subscription plan: ${value}`);
const mapped = LEGACY_PLAN_MAPPING[upperValue];
if (mapped) {
return new SubscriptionPlan(mapped);
} }
return new SubscriptionPlan(upperValue);
// Try direct match
if (PLAN_DETAILS[upperValue as SubscriptionPlanType]) {
return new SubscriptionPlan(upperValue as SubscriptionPlanType);
}
throw new Error(`Invalid subscription plan: ${value}`);
} }
// Named factories
static bronze(): SubscriptionPlan {
return new SubscriptionPlan('BRONZE');
}
static silver(): SubscriptionPlan {
return new SubscriptionPlan('SILVER');
}
static gold(): SubscriptionPlan {
return new SubscriptionPlan('GOLD');
}
static platinium(): SubscriptionPlan {
return new SubscriptionPlan('PLATINIUM');
}
// Legacy aliases
static free(): SubscriptionPlan { static free(): SubscriptionPlan {
return SubscriptionPlan.bronze(); return new SubscriptionPlan('FREE');
} }
static starter(): SubscriptionPlan { static starter(): SubscriptionPlan {
return SubscriptionPlan.silver(); return new SubscriptionPlan('STARTER');
} }
static pro(): SubscriptionPlan { static pro(): SubscriptionPlan {
return SubscriptionPlan.gold(); return new SubscriptionPlan('PRO');
} }
static enterprise(): SubscriptionPlan { static enterprise(): SubscriptionPlan {
return SubscriptionPlan.platinium(); return new SubscriptionPlan('ENTERPRISE');
} }
static getAllPlans(): SubscriptionPlan[] { static getAllPlans(): SubscriptionPlan[] {
return (['BRONZE', 'SILVER', 'GOLD', 'PLATINIUM'] as SubscriptionPlanType[]).map( return ['FREE', 'STARTER', 'PRO', 'ENTERPRISE'].map(
p => new SubscriptionPlan(p) (p) => new SubscriptionPlan(p as SubscriptionPlanType),
); );
} }
// Getters
get value(): SubscriptionPlanType { get value(): SubscriptionPlanType {
return this.plan; return this.plan;
} }
@ -205,33 +132,6 @@ export class SubscriptionPlan {
return PLAN_DETAILS[this.plan].features; return PLAN_DETAILS[this.plan].features;
} }
get maxShipmentsPerYear(): number {
return PLAN_DETAILS[this.plan].maxShipmentsPerYear;
}
get commissionRatePercent(): number {
return PLAN_DETAILS[this.plan].commissionRatePercent;
}
get statusBadge(): StatusBadge {
return PLAN_DETAILS[this.plan].statusBadge;
}
get supportLevel(): SupportLevel {
return PLAN_DETAILS[this.plan].supportLevel;
}
get planFeatures(): readonly PlanFeature[] {
return PLAN_DETAILS[this.plan].planFeatures;
}
/**
* Check if this plan includes a specific feature
*/
hasFeature(feature: PlanFeature): boolean {
return this.planFeatures.includes(feature);
}
/** /**
* Returns true if this plan has unlimited licenses * Returns true if this plan has unlimited licenses
*/ */
@ -239,32 +139,18 @@ export class SubscriptionPlan {
return this.maxLicenses === -1; return this.maxLicenses === -1;
} }
/**
* Returns true if this plan has unlimited shipments
*/
hasUnlimitedShipments(): boolean {
return this.maxShipmentsPerYear === -1;
}
/** /**
* Returns true if this is a paid plan * Returns true if this is a paid plan
*/ */
isPaid(): boolean { isPaid(): boolean {
return this.plan !== 'BRONZE'; return this.plan !== 'FREE';
} }
/** /**
* Returns true if this is the free (Bronze) plan * Returns true if this is the free plan
*/ */
isFree(): boolean { isFree(): boolean {
return this.plan === 'BRONZE'; return this.plan === 'FREE';
}
/**
* Returns true if this plan has custom pricing (Platinium)
*/
isCustomPricing(): boolean {
return this.plan === 'PLATINIUM';
} }
/** /**
@ -279,7 +165,12 @@ export class SubscriptionPlan {
* Check if upgrade to target plan is allowed * Check if upgrade to target plan is allowed
*/ */
canUpgradeTo(targetPlan: SubscriptionPlan): boolean { canUpgradeTo(targetPlan: SubscriptionPlan): boolean {
const planOrder: SubscriptionPlanType[] = ['BRONZE', 'SILVER', 'GOLD', 'PLATINIUM']; const planOrder: SubscriptionPlanType[] = [
'FREE',
'STARTER',
'PRO',
'ENTERPRISE',
];
const currentIndex = planOrder.indexOf(this.plan); const currentIndex = planOrder.indexOf(this.plan);
const targetIndex = planOrder.indexOf(targetPlan.value); const targetIndex = planOrder.indexOf(targetPlan.value);
return targetIndex > currentIndex; return targetIndex > currentIndex;
@ -289,7 +180,12 @@ export class SubscriptionPlan {
* Check if downgrade to target plan is allowed given current user count * Check if downgrade to target plan is allowed given current user count
*/ */
canDowngradeTo(targetPlan: SubscriptionPlan, currentUserCount: number): boolean { canDowngradeTo(targetPlan: SubscriptionPlan, currentUserCount: number): boolean {
const planOrder: SubscriptionPlanType[] = ['BRONZE', 'SILVER', 'GOLD', 'PLATINIUM']; const planOrder: SubscriptionPlanType[] = [
'FREE',
'STARTER',
'PRO',
'ENTERPRISE',
];
const currentIndex = planOrder.indexOf(this.plan); const currentIndex = planOrder.indexOf(this.plan);
const targetIndex = planOrder.indexOf(targetPlan.value); const targetIndex = planOrder.indexOf(targetPlan.value);

View File

@ -191,7 +191,9 @@ export class SubscriptionStatus {
*/ */
transitionTo(newStatus: SubscriptionStatus): SubscriptionStatus { transitionTo(newStatus: SubscriptionStatus): SubscriptionStatus {
if (!this.canTransitionTo(newStatus)) { if (!this.canTransitionTo(newStatus)) {
throw new Error(`Invalid status transition from ${this.status} to ${newStatus.value}`); throw new Error(
`Invalid status transition from ${this.status} to ${newStatus.value}`,
);
} }
return newStatus; return newStatus;
} }

View File

@ -618,8 +618,6 @@ export class EmailAdapter implements EmailPort {
html, html,
}); });
this.logger.log( this.logger.log(`New documents notification sent to ${carrierEmail} for booking ${data.bookingId}`);
`New documents notification sent to ${carrierEmail} for booking ${data.bookingId}`
);
} }
} }

View File

@ -1,50 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
SiretVerificationPort,
SiretVerificationResult,
} from '@domain/ports/out/siret-verification.port';
@Injectable()
export class PappersSiretAdapter implements SiretVerificationPort {
private readonly logger = new Logger(PappersSiretAdapter.name);
private readonly apiKey: string;
private readonly baseUrl = 'https://api.pappers.fr/v2';
constructor(private readonly configService: ConfigService) {
this.apiKey = this.configService.get<string>('PAPPERS_API_KEY', '');
}
async verify(siret: string): Promise<SiretVerificationResult> {
if (!this.apiKey) {
this.logger.warn('PAPPERS_API_KEY not configured, skipping SIRET verification');
return { valid: false };
}
try {
const url = `${this.baseUrl}/entreprise?api_token=${this.apiKey}&siret=${siret}`;
const response = await fetch(url);
if (!response.ok) {
if (response.status === 404) {
return { valid: false };
}
this.logger.error(`Pappers API error: ${response.status} ${response.statusText}`);
return { valid: false };
}
const data = await response.json();
return {
valid: true,
companyName: data.nom_entreprise || data.denomination,
address: data.siege?.adresse_ligne_1
? `${data.siege.adresse_ligne_1}, ${data.siege.code_postal} ${data.siege.ville}`
: undefined,
};
} catch (error: any) {
this.logger.error(`SIRET verification failed: ${error?.message}`, error?.stack);
return { valid: false };
}
}
}

View File

@ -92,18 +92,6 @@ export class BookingOrmEntity {
@Column({ name: 'special_instructions', type: 'text', nullable: true }) @Column({ name: 'special_instructions', type: 'text', nullable: true })
specialInstructions: string | null; specialInstructions: string | null;
@Column({ name: 'commission_rate', type: 'decimal', precision: 5, scale: 2, nullable: true })
commissionRate: number | null;
@Column({
name: 'commission_amount_eur',
type: 'decimal',
precision: 12,
scale: 2,
nullable: true,
})
commissionAmountEur: number | null;
@CreateDateColumn({ name: 'created_at' }) @CreateDateColumn({ name: 'created_at' })
createdAt: Date; createdAt: Date;

View File

@ -1,58 +1,58 @@
/** /**
* Cookie Consent ORM Entity (Infrastructure Layer) * Cookie Consent ORM Entity (Infrastructure Layer)
* *
* TypeORM entity for cookie consent persistence * TypeORM entity for cookie consent persistence
*/ */
import { import {
Entity, Entity,
Column, Column,
PrimaryColumn, PrimaryColumn,
CreateDateColumn, CreateDateColumn,
UpdateDateColumn, UpdateDateColumn,
Index, Index,
ManyToOne, ManyToOne,
JoinColumn, JoinColumn,
} from 'typeorm'; } from 'typeorm';
import { UserOrmEntity } from './user.orm-entity'; import { UserOrmEntity } from './user.orm-entity';
@Entity('cookie_consents') @Entity('cookie_consents')
@Index('idx_cookie_consents_user', ['userId']) @Index('idx_cookie_consents_user', ['userId'])
export class CookieConsentOrmEntity { export class CookieConsentOrmEntity {
@PrimaryColumn('uuid') @PrimaryColumn('uuid')
id: string; id: string;
@Column({ name: 'user_id', type: 'uuid', unique: true }) @Column({ name: 'user_id', type: 'uuid', unique: true })
userId: string; userId: string;
@ManyToOne(() => UserOrmEntity, { onDelete: 'CASCADE' }) @ManyToOne(() => UserOrmEntity, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' }) @JoinColumn({ name: 'user_id' })
user: UserOrmEntity; user: UserOrmEntity;
@Column({ type: 'boolean', default: true }) @Column({ type: 'boolean', default: true })
essential: boolean; essential: boolean;
@Column({ type: 'boolean', default: false }) @Column({ type: 'boolean', default: false })
functional: boolean; functional: boolean;
@Column({ type: 'boolean', default: false }) @Column({ type: 'boolean', default: false })
analytics: boolean; analytics: boolean;
@Column({ type: 'boolean', default: false }) @Column({ type: 'boolean', default: false })
marketing: boolean; marketing: boolean;
@Column({ name: 'ip_address', type: 'varchar', length: 45, nullable: true }) @Column({ name: 'ip_address', type: 'varchar', length: 45, nullable: true })
ipAddress: string | null; ipAddress: string | null;
@Column({ name: 'user_agent', type: 'text', nullable: true }) @Column({ name: 'user_agent', type: 'text', nullable: true })
userAgent: string | null; userAgent: string | null;
@Column({ name: 'consent_date', type: 'timestamp', default: () => 'NOW()' }) @Column({ name: 'consent_date', type: 'timestamp', default: () => 'NOW()' })
consentDate: Date; consentDate: Date;
@CreateDateColumn({ name: 'created_at' }) @CreateDateColumn({ name: 'created_at' })
createdAt: Date; createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' }) @UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date; updatedAt: Date;
} }

View File

@ -75,11 +75,11 @@ export class CsvBookingOrmEntity {
@Column({ @Column({
name: 'status', name: 'status',
type: 'enum', type: 'enum',
enum: ['PENDING_PAYMENT', 'PENDING_BANK_TRANSFER', 'PENDING', 'ACCEPTED', 'REJECTED', 'CANCELLED'], enum: ['PENDING', 'ACCEPTED', 'REJECTED', 'CANCELLED'],
default: 'PENDING_PAYMENT', default: 'PENDING',
}) })
@Index() @Index()
status: 'PENDING_PAYMENT' | 'PENDING_BANK_TRANSFER' | 'PENDING' | 'ACCEPTED' | 'REJECTED' | 'CANCELLED'; status: 'PENDING' | 'ACCEPTED' | 'REJECTED' | 'CANCELLED';
@Column({ name: 'documents', type: 'jsonb' }) @Column({ name: 'documents', type: 'jsonb' })
documents: Array<{ documents: Array<{
@ -141,21 +141,6 @@ export class CsvBookingOrmEntity {
@Column({ name: 'carrier_notes', type: 'text', nullable: true }) @Column({ name: 'carrier_notes', type: 'text', nullable: true })
carrierNotes: string | null; carrierNotes: string | null;
@Column({ name: 'stripe_payment_intent_id', type: 'varchar', length: 255, nullable: true })
stripePaymentIntentId: string | null;
@Column({ name: 'commission_rate', type: 'decimal', precision: 5, scale: 2, nullable: true })
commissionRate: number | null;
@Column({
name: 'commission_amount_eur',
type: 'decimal',
precision: 12,
scale: 2,
nullable: true,
})
commissionAmountEur: number | null;
@CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone' }) @CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone' })
createdAt: Date; createdAt: Date;

View File

@ -5,7 +5,14 @@
* Represents user licenses linked to subscriptions. * Represents user licenses linked to subscriptions.
*/ */
import { Entity, Column, PrimaryGeneratedColumn, ManyToOne, JoinColumn, Index } from 'typeorm'; import {
Entity,
Column,
PrimaryGeneratedColumn,
ManyToOne,
JoinColumn,
Index,
} from 'typeorm';
import { SubscriptionOrmEntity } from './subscription.orm-entity'; import { SubscriptionOrmEntity } from './subscription.orm-entity';
import { UserOrmEntity } from './user.orm-entity'; import { UserOrmEntity } from './user.orm-entity';
@ -23,7 +30,7 @@ export class LicenseOrmEntity {
@Column({ name: 'subscription_id', type: 'uuid' }) @Column({ name: 'subscription_id', type: 'uuid' })
subscriptionId: string; subscriptionId: string;
@ManyToOne(() => SubscriptionOrmEntity, subscription => subscription.licenses, { @ManyToOne(() => SubscriptionOrmEntity, (subscription) => subscription.licenses, {
onDelete: 'CASCADE', onDelete: 'CASCADE',
}) })
@JoinColumn({ name: 'subscription_id' }) @JoinColumn({ name: 'subscription_id' })

View File

@ -56,15 +56,6 @@ export class OrganizationOrmEntity {
@Column({ type: 'jsonb', default: '[]' }) @Column({ type: 'jsonb', default: '[]' })
documents: any[]; documents: any[];
@Column({ type: 'varchar', length: 14, nullable: true })
siret: string | null;
@Column({ name: 'siret_verified', type: 'boolean', default: false })
siretVerified: boolean;
@Column({ name: 'status_badge', type: 'varchar', length: 20, default: 'none' })
statusBadge: string;
@Column({ name: 'is_carrier', type: 'boolean', default: false }) @Column({ name: 'is_carrier', type: 'boolean', default: false })
isCarrier: boolean; isCarrier: boolean;

View File

@ -19,7 +19,7 @@ import {
import { OrganizationOrmEntity } from './organization.orm-entity'; import { OrganizationOrmEntity } from './organization.orm-entity';
import { LicenseOrmEntity } from './license.orm-entity'; import { LicenseOrmEntity } from './license.orm-entity';
export type SubscriptionPlanOrmType = 'BRONZE' | 'SILVER' | 'GOLD' | 'PLATINIUM'; export type SubscriptionPlanOrmType = 'FREE' | 'STARTER' | 'PRO' | 'ENTERPRISE';
export type SubscriptionStatusOrmType = export type SubscriptionStatusOrmType =
| 'ACTIVE' | 'ACTIVE'
@ -51,8 +51,8 @@ export class SubscriptionOrmEntity {
// Plan information // Plan information
@Column({ @Column({
type: 'enum', type: 'enum',
enum: ['BRONZE', 'SILVER', 'GOLD', 'PLATINIUM'], enum: ['FREE', 'STARTER', 'PRO', 'ENTERPRISE'],
default: 'BRONZE', default: 'FREE',
}) })
plan: SubscriptionPlanOrmType; plan: SubscriptionPlanOrmType;
@ -103,6 +103,6 @@ export class SubscriptionOrmEntity {
updatedAt: Date; updatedAt: Date;
// Relations // Relations
@OneToMany(() => LicenseOrmEntity, license => license.subscription) @OneToMany(() => LicenseOrmEntity, (license) => license.subscription)
licenses: LicenseOrmEntity[]; licenses: LicenseOrmEntity[];
} }

View File

@ -27,8 +27,6 @@ export class BookingOrmMapper {
orm.consignee = this.partyToJson(domain.consignee); orm.consignee = this.partyToJson(domain.consignee);
orm.cargoDescription = domain.cargoDescription; orm.cargoDescription = domain.cargoDescription;
orm.specialInstructions = domain.specialInstructions || null; orm.specialInstructions = domain.specialInstructions || null;
orm.commissionRate = domain.commissionRate ?? null;
orm.commissionAmountEur = domain.commissionAmountEur ?? null;
orm.createdAt = domain.createdAt; orm.createdAt = domain.createdAt;
orm.updatedAt = domain.updatedAt; orm.updatedAt = domain.updatedAt;
@ -54,9 +52,6 @@ export class BookingOrmMapper {
cargoDescription: orm.cargoDescription, cargoDescription: orm.cargoDescription,
containers: orm.containers ? orm.containers.map(c => this.ormToContainer(c)) : [], containers: orm.containers ? orm.containers.map(c => this.ormToContainer(c)) : [],
specialInstructions: orm.specialInstructions || undefined, specialInstructions: orm.specialInstructions || undefined,
commissionRate: orm.commissionRate != null ? Number(orm.commissionRate) : undefined,
commissionAmountEur:
orm.commissionAmountEur != null ? Number(orm.commissionAmountEur) : undefined,
createdAt: orm.createdAt, createdAt: orm.createdAt,
updatedAt: orm.updatedAt, updatedAt: orm.updatedAt,
}; };

View File

@ -42,10 +42,7 @@ export class CsvBookingMapper {
ormEntity.respondedAt, ormEntity.respondedAt,
ormEntity.notes, ormEntity.notes,
ormEntity.rejectionReason, ormEntity.rejectionReason,
ormEntity.bookingNumber ?? undefined, ormEntity.bookingNumber ?? undefined
ormEntity.commissionRate != null ? Number(ormEntity.commissionRate) : undefined,
ormEntity.commissionAmountEur != null ? Number(ormEntity.commissionAmountEur) : undefined,
ormEntity.stripePaymentIntentId ?? undefined
); );
} }
@ -69,16 +66,13 @@ export class CsvBookingMapper {
primaryCurrency: domain.primaryCurrency, primaryCurrency: domain.primaryCurrency,
transitDays: domain.transitDays, transitDays: domain.transitDays,
containerType: domain.containerType, containerType: domain.containerType,
status: domain.status as CsvBookingOrmEntity['status'], status: domain.status as 'PENDING' | 'ACCEPTED' | 'REJECTED' | 'CANCELLED',
documents: domain.documents as any, documents: domain.documents as any,
confirmationToken: domain.confirmationToken, confirmationToken: domain.confirmationToken,
requestedAt: domain.requestedAt, requestedAt: domain.requestedAt,
respondedAt: domain.respondedAt, respondedAt: domain.respondedAt,
notes: domain.notes, notes: domain.notes,
rejectionReason: domain.rejectionReason, rejectionReason: domain.rejectionReason,
stripePaymentIntentId: domain.stripePaymentIntentId ?? null,
commissionRate: domain.commissionRate ?? null,
commissionAmountEur: domain.commissionAmountEur ?? null,
}; };
} }
@ -87,13 +81,10 @@ export class CsvBookingMapper {
*/ */
static toOrmUpdate(domain: CsvBooking): Partial<CsvBookingOrmEntity> { static toOrmUpdate(domain: CsvBooking): Partial<CsvBookingOrmEntity> {
return { return {
status: domain.status as CsvBookingOrmEntity['status'], status: domain.status as 'PENDING' | 'ACCEPTED' | 'REJECTED' | 'CANCELLED',
respondedAt: domain.respondedAt, respondedAt: domain.respondedAt,
notes: domain.notes, notes: domain.notes,
rejectionReason: domain.rejectionReason, rejectionReason: domain.rejectionReason,
stripePaymentIntentId: domain.stripePaymentIntentId ?? null,
commissionRate: domain.commissionRate ?? null,
commissionAmountEur: domain.commissionAmountEur ?? null,
}; };
} }

View File

@ -43,6 +43,6 @@ export class LicenseOrmMapper {
* Map array of ORM entities to domain entities * Map array of ORM entities to domain entities
*/ */
static toDomainMany(orms: LicenseOrmEntity[]): License[] { static toDomainMany(orms: LicenseOrmEntity[]): License[] {
return orms.map(orm => this.toDomain(orm)); return orms.map((orm) => this.toDomain(orm));
} }
} }

View File

@ -30,9 +30,6 @@ export class OrganizationOrmMapper {
orm.addressCountry = props.address.country; orm.addressCountry = props.address.country;
orm.logoUrl = props.logoUrl || null; orm.logoUrl = props.logoUrl || null;
orm.documents = props.documents; orm.documents = props.documents;
orm.siret = props.siret || null;
orm.siretVerified = props.siretVerified;
orm.statusBadge = props.statusBadge;
orm.isActive = props.isActive; orm.isActive = props.isActive;
orm.createdAt = props.createdAt; orm.createdAt = props.createdAt;
orm.updatedAt = props.updatedAt; orm.updatedAt = props.updatedAt;
@ -62,9 +59,6 @@ export class OrganizationOrmMapper {
}, },
logoUrl: orm.logoUrl || undefined, logoUrl: orm.logoUrl || undefined,
documents: orm.documents || [], documents: orm.documents || [],
siret: orm.siret || undefined,
siretVerified: orm.siretVerified ?? false,
statusBadge: (orm.statusBadge as 'none' | 'silver' | 'gold' | 'platinium') || 'none',
isActive: orm.isActive, isActive: orm.isActive,
createdAt: orm.createdAt, createdAt: orm.createdAt,
updatedAt: orm.updatedAt, updatedAt: orm.updatedAt,

View File

@ -53,6 +53,6 @@ export class SubscriptionOrmMapper {
* Map array of ORM entities to domain entities * Map array of ORM entities to domain entities
*/ */
static toDomainMany(orms: SubscriptionOrmEntity[]): Subscription[] { static toDomainMany(orms: SubscriptionOrmEntity[]): Subscription[] {
return orms.map(orm => this.toDomain(orm)); return orms.map((orm) => this.toDomain(orm));
} }
} }

View File

@ -1,62 +1,62 @@
/** /**
* Migration: Create Cookie Consents Table * Migration: Create Cookie Consents Table
* GDPR compliant cookie preference storage * GDPR compliant cookie preference storage
*/ */
import { MigrationInterface, QueryRunner } from 'typeorm'; import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateCookieConsent1738100000000 implements MigrationInterface { export class CreateCookieConsent1738100000000 implements MigrationInterface {
name = 'CreateCookieConsent1738100000000'; name = 'CreateCookieConsent1738100000000';
public async up(queryRunner: QueryRunner): Promise<void> { public async up(queryRunner: QueryRunner): Promise<void> {
// Create cookie_consents table // Create cookie_consents table
await queryRunner.query(` await queryRunner.query(`
CREATE TABLE "cookie_consents" ( CREATE TABLE "cookie_consents" (
"id" UUID NOT NULL DEFAULT uuid_generate_v4(), "id" UUID NOT NULL DEFAULT uuid_generate_v4(),
"user_id" UUID NOT NULL, "user_id" UUID NOT NULL,
"essential" BOOLEAN NOT NULL DEFAULT TRUE, "essential" BOOLEAN NOT NULL DEFAULT TRUE,
"functional" BOOLEAN NOT NULL DEFAULT FALSE, "functional" BOOLEAN NOT NULL DEFAULT FALSE,
"analytics" BOOLEAN NOT NULL DEFAULT FALSE, "analytics" BOOLEAN NOT NULL DEFAULT FALSE,
"marketing" BOOLEAN NOT NULL DEFAULT FALSE, "marketing" BOOLEAN NOT NULL DEFAULT FALSE,
"ip_address" VARCHAR(45) NULL, "ip_address" VARCHAR(45) NULL,
"user_agent" TEXT NULL, "user_agent" TEXT NULL,
"consent_date" TIMESTAMP NOT NULL DEFAULT NOW(), "consent_date" TIMESTAMP NOT NULL DEFAULT NOW(),
"created_at" TIMESTAMP NOT NULL DEFAULT NOW(), "created_at" TIMESTAMP NOT NULL DEFAULT NOW(),
"updated_at" TIMESTAMP NOT NULL DEFAULT NOW(), "updated_at" TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT "pk_cookie_consents" PRIMARY KEY ("id"), CONSTRAINT "pk_cookie_consents" PRIMARY KEY ("id"),
CONSTRAINT "uq_cookie_consents_user" UNIQUE ("user_id"), CONSTRAINT "uq_cookie_consents_user" UNIQUE ("user_id"),
CONSTRAINT "fk_cookie_consents_user" FOREIGN KEY ("user_id") CONSTRAINT "fk_cookie_consents_user" FOREIGN KEY ("user_id")
REFERENCES "users"("id") ON DELETE CASCADE REFERENCES "users"("id") ON DELETE CASCADE
) )
`); `);
// Create index for fast user lookups // Create index for fast user lookups
await queryRunner.query(` await queryRunner.query(`
CREATE INDEX "idx_cookie_consents_user" ON "cookie_consents" ("user_id") CREATE INDEX "idx_cookie_consents_user" ON "cookie_consents" ("user_id")
`); `);
// Add comments // Add comments
await queryRunner.query(` await queryRunner.query(`
COMMENT ON TABLE "cookie_consents" IS 'GDPR compliant cookie consent preferences per user' COMMENT ON TABLE "cookie_consents" IS 'GDPR compliant cookie consent preferences per user'
`); `);
await queryRunner.query(` await queryRunner.query(`
COMMENT ON COLUMN "cookie_consents"."essential" IS 'Essential cookies - always true, required for functionality' COMMENT ON COLUMN "cookie_consents"."essential" IS 'Essential cookies - always true, required for functionality'
`); `);
await queryRunner.query(` await queryRunner.query(`
COMMENT ON COLUMN "cookie_consents"."functional" IS 'Functional cookies - preferences, language, etc.' COMMENT ON COLUMN "cookie_consents"."functional" IS 'Functional cookies - preferences, language, etc.'
`); `);
await queryRunner.query(` await queryRunner.query(`
COMMENT ON COLUMN "cookie_consents"."analytics" IS 'Analytics cookies - Google Analytics, Sentry, etc.' COMMENT ON COLUMN "cookie_consents"."analytics" IS 'Analytics cookies - Google Analytics, Sentry, etc.'
`); `);
await queryRunner.query(` await queryRunner.query(`
COMMENT ON COLUMN "cookie_consents"."marketing" IS 'Marketing cookies - ads, tracking, remarketing' COMMENT ON COLUMN "cookie_consents"."marketing" IS 'Marketing cookies - ads, tracking, remarketing'
`); `);
await queryRunner.query(` await queryRunner.query(`
COMMENT ON COLUMN "cookie_consents"."ip_address" IS 'IP address at time of consent for GDPR audit trail' COMMENT ON COLUMN "cookie_consents"."ip_address" IS 'IP address at time of consent for GDPR audit trail'
`); `);
} }
public async down(queryRunner: QueryRunner): Promise<void> { public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE "cookie_consents"`); await queryRunner.query(`DROP TABLE "cookie_consents"`);
} }
} }

View File

@ -1,92 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Rename subscription plans:
* FREE -> BRONZE, STARTER -> SILVER, PRO -> GOLD, ENTERPRISE -> PLATINIUM
*
* PostgreSQL does not support removing values from an enum type directly,
* so we create a new enum, migrate the column, and drop the old one.
*/
export class RenamePlansToBronzeSilverGoldPlatinium1740000000001 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
// Step 1: Create new enum type
await queryRunner.query(
`CREATE TYPE "subscription_plan_enum_new" AS ENUM ('BRONZE', 'SILVER', 'GOLD', 'PLATINIUM')`
);
// Step 2: Convert the column to VARCHAR temporarily so we can update values
await queryRunner.query(`ALTER TABLE "subscriptions" ALTER COLUMN "plan" TYPE VARCHAR(20)`);
// Step 3: Update existing values
await queryRunner.query(`UPDATE "subscriptions" SET "plan" = 'BRONZE' WHERE "plan" = 'FREE'`);
await queryRunner.query(
`UPDATE "subscriptions" SET "plan" = 'SILVER' WHERE "plan" = 'STARTER'`
);
await queryRunner.query(`UPDATE "subscriptions" SET "plan" = 'GOLD' WHERE "plan" = 'PRO'`);
await queryRunner.query(
`UPDATE "subscriptions" SET "plan" = 'PLATINIUM' WHERE "plan" = 'ENTERPRISE'`
);
// Step 4: Drop existing default (required before changing enum type)
await queryRunner.query(`ALTER TABLE "subscriptions" ALTER COLUMN "plan" DROP DEFAULT`);
// Step 5: Set column to new enum type
await queryRunner.query(
`ALTER TABLE "subscriptions" ALTER COLUMN "plan" TYPE "subscription_plan_enum_new" USING "plan"::"subscription_plan_enum_new"`
);
// Step 6: Set new default
await queryRunner.query(`ALTER TABLE "subscriptions" ALTER COLUMN "plan" SET DEFAULT 'BRONZE'`);
// Step 7: Drop old enum type (name may vary — TypeORM often creates it as subscriptions_plan_enum)
// We handle both possible names
await queryRunner.query(`
DO $$ BEGIN
IF EXISTS (SELECT 1 FROM pg_type WHERE typname = 'subscriptions_plan_enum') THEN
DROP TYPE "subscriptions_plan_enum";
END IF;
END $$;
`);
// Step 8: Rename new enum to standard name
await queryRunner.query(
`ALTER TYPE "subscription_plan_enum_new" RENAME TO "subscriptions_plan_enum"`
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// Reverse: create old enum, migrate back
await queryRunner.query(
`CREATE TYPE "subscription_plan_enum_old" AS ENUM ('FREE', 'STARTER', 'PRO', 'ENTERPRISE')`
);
await queryRunner.query(`ALTER TABLE "subscriptions" ALTER COLUMN "plan" TYPE VARCHAR(20)`);
await queryRunner.query(`UPDATE "subscriptions" SET "plan" = 'FREE' WHERE "plan" = 'BRONZE'`);
await queryRunner.query(
`UPDATE "subscriptions" SET "plan" = 'STARTER' WHERE "plan" = 'SILVER'`
);
await queryRunner.query(`UPDATE "subscriptions" SET "plan" = 'PRO' WHERE "plan" = 'GOLD'`);
await queryRunner.query(
`UPDATE "subscriptions" SET "plan" = 'ENTERPRISE' WHERE "plan" = 'PLATINIUM'`
);
await queryRunner.query(
`ALTER TABLE "subscriptions" ALTER COLUMN "plan" TYPE "subscription_plan_enum_old" USING "plan"::"subscription_plan_enum_old"`
);
await queryRunner.query(`ALTER TABLE "subscriptions" ALTER COLUMN "plan" SET DEFAULT 'FREE'`);
await queryRunner.query(`
DO $$ BEGIN
IF EXISTS (SELECT 1 FROM pg_type WHERE typname = 'subscriptions_plan_enum') THEN
DROP TYPE "subscriptions_plan_enum";
END IF;
END $$;
`);
await queryRunner.query(
`ALTER TYPE "subscription_plan_enum_old" RENAME TO "subscriptions_plan_enum"`
);
}
}

View File

@ -1,43 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddCommissionFields1740000000002 implements MigrationInterface {
name = 'AddCommissionFields1740000000002';
public async up(queryRunner: QueryRunner): Promise<void> {
// Add commission columns to csv_bookings (bookings table may not exist yet)
await queryRunner.query(`
ALTER TABLE "csv_bookings"
ADD COLUMN IF NOT EXISTS "commission_rate" DECIMAL(5,2),
ADD COLUMN IF NOT EXISTS "commission_amount_eur" DECIMAL(12,2)
`);
// Only alter bookings table if it exists
await queryRunner.query(`
DO $$ BEGIN
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'bookings') THEN
ALTER TABLE "bookings"
ADD COLUMN "commission_rate" DECIMAL(5,2),
ADD COLUMN "commission_amount_eur" DECIMAL(12,2);
END IF;
END $$;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE "csv_bookings"
DROP COLUMN IF EXISTS "commission_amount_eur",
DROP COLUMN IF EXISTS "commission_rate"
`);
await queryRunner.query(`
DO $$ BEGIN
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'bookings') THEN
ALTER TABLE "bookings"
DROP COLUMN "commission_amount_eur",
DROP COLUMN "commission_rate";
END IF;
END $$;
`);
}
}

View File

@ -1,23 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddSiretAndStatusBadgeToOrganizations1740000000003 implements MigrationInterface {
name = 'AddSiretAndStatusBadgeToOrganizations1740000000003';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE "organizations"
ADD COLUMN "siret" VARCHAR(14),
ADD COLUMN "siret_verified" BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN "status_badge" VARCHAR(20) NOT NULL DEFAULT 'none'
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE "organizations"
DROP COLUMN "status_badge",
DROP COLUMN "siret_verified",
DROP COLUMN "siret"
`);
}
}

View File

@ -1,75 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Migration: Add PENDING_PAYMENT status to csv_bookings enum + stripe_payment_intent_id column
*/
export class AddPendingPaymentStatus1740000000004 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
// Drop the default before changing enum type
await queryRunner.query(`
ALTER TABLE "csv_bookings" ALTER COLUMN "status" DROP DEFAULT
`);
// Create new enum with PENDING_PAYMENT
await queryRunner.query(`
CREATE TYPE "csv_booking_status_new" AS ENUM ('PENDING_PAYMENT', 'PENDING', 'ACCEPTED', 'REJECTED', 'CANCELLED')
`);
// Swap column to new enum type
await queryRunner.query(`
ALTER TABLE "csv_bookings"
ALTER COLUMN "status" TYPE "csv_booking_status_new"
USING "status"::text::"csv_booking_status_new"
`);
// Drop old enum and rename new
await queryRunner.query(`DROP TYPE "csv_booking_status"`);
await queryRunner.query(`ALTER TYPE "csv_booking_status_new" RENAME TO "csv_booking_status"`);
// Set new default
await queryRunner.query(`
ALTER TABLE "csv_bookings" ALTER COLUMN "status" SET DEFAULT 'PENDING_PAYMENT'
`);
// Add stripe_payment_intent_id column
await queryRunner.query(`
ALTER TABLE "csv_bookings"
ADD COLUMN IF NOT EXISTS "stripe_payment_intent_id" VARCHAR(255)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// Remove stripe_payment_intent_id column
await queryRunner.query(`
ALTER TABLE "csv_bookings" DROP COLUMN IF EXISTS "stripe_payment_intent_id"
`);
// Update any PENDING_PAYMENT rows to PENDING
await queryRunner.query(`
UPDATE "csv_bookings" SET "status" = 'PENDING' WHERE "status" = 'PENDING_PAYMENT'
`);
// Drop default
await queryRunner.query(`
ALTER TABLE "csv_bookings" ALTER COLUMN "status" DROP DEFAULT
`);
// Recreate original enum without PENDING_PAYMENT
await queryRunner.query(`
CREATE TYPE "csv_booking_status_old" AS ENUM ('PENDING', 'ACCEPTED', 'REJECTED', 'CANCELLED')
`);
await queryRunner.query(`
ALTER TABLE "csv_bookings"
ALTER COLUMN "status" TYPE "csv_booking_status_old"
USING "status"::text::"csv_booking_status_old"
`);
await queryRunner.query(`DROP TYPE "csv_booking_status"`);
await queryRunner.query(`ALTER TYPE "csv_booking_status_old" RENAME TO "csv_booking_status"`);
await queryRunner.query(`
ALTER TABLE "csv_bookings" ALTER COLUMN "status" SET DEFAULT 'PENDING'
`);
}
}

View File

@ -1,75 +0,0 @@
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<void> {
// 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<void> {
// 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'
`);
}
}

View File

@ -1,32 +0,0 @@
/**
* Shipment Counter Repository
*
* Counts total shipments (bookings + CSV bookings) for an organization in a year.
* Used to enforce Bronze plan's 12 shipments/year limit.
*/
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ShipmentCounterPort } from '@domain/ports/out/shipment-counter.port';
import { CsvBookingOrmEntity } from '../entities/csv-booking.orm-entity';
@Injectable()
export class TypeOrmShipmentCounterRepository implements ShipmentCounterPort {
constructor(
@InjectRepository(CsvBookingOrmEntity)
private readonly csvBookingRepository: Repository<CsvBookingOrmEntity>
) {}
async countShipmentsForOrganizationInYear(organizationId: string, year: number): Promise<number> {
const startOfYear = new Date(year, 0, 1);
const startOfNextYear = new Date(year + 1, 0, 1);
return this.csvBookingRepository
.createQueryBuilder('csv_booking')
.where('csv_booking.organization_id = :organizationId', { organizationId })
.andWhere('csv_booking.created_at >= :start', { start: startOfYear })
.andWhere('csv_booking.created_at < :end', { end: startOfNextYear })
.getCount();
}
}

View File

@ -16,7 +16,7 @@ import { LicenseOrmMapper } from '../mappers/license-orm.mapper';
export class TypeOrmLicenseRepository implements LicenseRepository { export class TypeOrmLicenseRepository implements LicenseRepository {
constructor( constructor(
@InjectRepository(LicenseOrmEntity) @InjectRepository(LicenseOrmEntity)
private readonly repository: Repository<LicenseOrmEntity> private readonly repository: Repository<LicenseOrmEntity>,
) {} ) {}
async save(license: License): Promise<License> { async save(license: License): Promise<License> {

View File

@ -16,7 +16,7 @@ import { SubscriptionOrmMapper } from '../mappers/subscription-orm.mapper';
export class TypeOrmSubscriptionRepository implements SubscriptionRepository { export class TypeOrmSubscriptionRepository implements SubscriptionRepository {
constructor( constructor(
@InjectRepository(SubscriptionOrmEntity) @InjectRepository(SubscriptionOrmEntity)
private readonly repository: Repository<SubscriptionOrmEntity> private readonly repository: Repository<SubscriptionOrmEntity>,
) {} ) {}
async save(subscription: Subscription): Promise<Subscription> { async save(subscription: Subscription): Promise<Subscription> {
@ -35,7 +35,9 @@ export class TypeOrmSubscriptionRepository implements SubscriptionRepository {
return orm ? SubscriptionOrmMapper.toDomain(orm) : null; return orm ? SubscriptionOrmMapper.toDomain(orm) : null;
} }
async findByStripeSubscriptionId(stripeSubscriptionId: string): Promise<Subscription | null> { async findByStripeSubscriptionId(
stripeSubscriptionId: string,
): Promise<Subscription | null> {
const orm = await this.repository.findOne({ where: { stripeSubscriptionId } }); const orm = await this.repository.findOne({ where: { stripeSubscriptionId } });
return orm ? SubscriptionOrmMapper.toDomain(orm) : null; return orm ? SubscriptionOrmMapper.toDomain(orm) : null;
} }

View File

@ -11,8 +11,6 @@ import {
StripePort, StripePort,
CreateCheckoutSessionInput, CreateCheckoutSessionInput,
CreateCheckoutSessionOutput, CreateCheckoutSessionOutput,
CreateCommissionCheckoutInput,
CreateCommissionCheckoutOutput,
CreatePortalSessionInput, CreatePortalSessionInput,
CreatePortalSessionOutput, CreatePortalSessionOutput,
StripeSubscriptionData, StripeSubscriptionData,
@ -44,46 +42,50 @@ export class StripeAdapter implements StripePort {
this.planPriceMap = new Map(); this.planPriceMap = new Map();
// Configure plan price IDs from environment // Configure plan price IDs from environment
const silverMonthly = this.configService.get<string>('STRIPE_SILVER_MONTHLY_PRICE_ID'); const starterMonthly = this.configService.get<string>('STRIPE_STARTER_MONTHLY_PRICE_ID');
const silverYearly = this.configService.get<string>('STRIPE_SILVER_YEARLY_PRICE_ID'); const starterYearly = this.configService.get<string>('STRIPE_STARTER_YEARLY_PRICE_ID');
const goldMonthly = this.configService.get<string>('STRIPE_GOLD_MONTHLY_PRICE_ID'); const proMonthly = this.configService.get<string>('STRIPE_PRO_MONTHLY_PRICE_ID');
const goldYearly = this.configService.get<string>('STRIPE_GOLD_YEARLY_PRICE_ID'); const proYearly = this.configService.get<string>('STRIPE_PRO_YEARLY_PRICE_ID');
const platiniumMonthly = this.configService.get<string>('STRIPE_PLATINIUM_MONTHLY_PRICE_ID'); const enterpriseMonthly = this.configService.get<string>('STRIPE_ENTERPRISE_MONTHLY_PRICE_ID');
const platiniumYearly = this.configService.get<string>('STRIPE_PLATINIUM_YEARLY_PRICE_ID'); const enterpriseYearly = this.configService.get<string>('STRIPE_ENTERPRISE_YEARLY_PRICE_ID');
if (silverMonthly) this.priceIdMap.set(silverMonthly, 'SILVER'); if (starterMonthly) this.priceIdMap.set(starterMonthly, 'STARTER');
if (silverYearly) this.priceIdMap.set(silverYearly, 'SILVER'); if (starterYearly) this.priceIdMap.set(starterYearly, 'STARTER');
if (goldMonthly) this.priceIdMap.set(goldMonthly, 'GOLD'); if (proMonthly) this.priceIdMap.set(proMonthly, 'PRO');
if (goldYearly) this.priceIdMap.set(goldYearly, 'GOLD'); if (proYearly) this.priceIdMap.set(proYearly, 'PRO');
if (platiniumMonthly) this.priceIdMap.set(platiniumMonthly, 'PLATINIUM'); if (enterpriseMonthly) this.priceIdMap.set(enterpriseMonthly, 'ENTERPRISE');
if (platiniumYearly) this.priceIdMap.set(platiniumYearly, 'PLATINIUM'); if (enterpriseYearly) this.priceIdMap.set(enterpriseYearly, 'ENTERPRISE');
this.planPriceMap.set('SILVER', { this.planPriceMap.set('STARTER', {
monthly: silverMonthly || '', monthly: starterMonthly || '',
yearly: silverYearly || '', yearly: starterYearly || '',
}); });
this.planPriceMap.set('GOLD', { this.planPriceMap.set('PRO', {
monthly: goldMonthly || '', monthly: proMonthly || '',
yearly: goldYearly || '', yearly: proYearly || '',
}); });
this.planPriceMap.set('PLATINIUM', { this.planPriceMap.set('ENTERPRISE', {
monthly: platiniumMonthly || '', monthly: enterpriseMonthly || '',
yearly: platiniumYearly || '', yearly: enterpriseYearly || '',
}); });
} }
async createCheckoutSession( async createCheckoutSession(
input: CreateCheckoutSessionInput input: CreateCheckoutSessionInput,
): Promise<CreateCheckoutSessionOutput> { ): Promise<CreateCheckoutSessionOutput> {
const planPrices = this.planPriceMap.get(input.plan); const planPrices = this.planPriceMap.get(input.plan);
if (!planPrices) { if (!planPrices) {
throw new Error(`No price configuration for plan: ${input.plan}`); throw new Error(`No price configuration for plan: ${input.plan}`);
} }
const priceId = input.billingInterval === 'yearly' ? planPrices.yearly : planPrices.monthly; const priceId = input.billingInterval === 'yearly'
? planPrices.yearly
: planPrices.monthly;
if (!priceId) { if (!priceId) {
throw new Error(`No ${input.billingInterval} price configured for plan: ${input.plan}`); throw new Error(
`No ${input.billingInterval} price configured for plan: ${input.plan}`,
);
} }
const sessionParams: Stripe.Checkout.SessionCreateParams = { const sessionParams: Stripe.Checkout.SessionCreateParams = {
@ -117,7 +119,7 @@ export class StripeAdapter implements StripePort {
const session = await this.stripe.checkout.sessions.create(sessionParams); const session = await this.stripe.checkout.sessions.create(sessionParams);
this.logger.log( this.logger.log(
`Created checkout session ${session.id} for organization ${input.organizationId}` `Created checkout session ${session.id} for organization ${input.organizationId}`,
); );
return { return {
@ -126,46 +128,9 @@ export class StripeAdapter implements StripePort {
}; };
} }
async createCommissionCheckout( async createPortalSession(
input: CreateCommissionCheckoutInput input: CreatePortalSessionInput,
): Promise<CreateCommissionCheckoutOutput> { ): Promise<CreatePortalSessionOutput> {
const session = await this.stripe.checkout.sessions.create({
mode: 'payment',
payment_method_types: ['card'],
line_items: [
{
price_data: {
currency: input.currency,
unit_amount: input.amountCents,
product_data: {
name: 'Commission Xpeditis',
description: input.bookingDescription,
},
},
quantity: 1,
},
],
customer_email: input.customerEmail,
success_url: input.successUrl,
cancel_url: input.cancelUrl,
metadata: {
type: 'commission',
bookingId: input.bookingId,
organizationId: input.organizationId,
},
});
this.logger.log(
`Created commission checkout session ${session.id} for booking ${input.bookingId}`
);
return {
sessionId: session.id,
sessionUrl: session.url || '',
};
}
async createPortalSession(input: CreatePortalSessionInput): Promise<CreatePortalSessionOutput> {
const session = await this.stripe.billingPortal.sessions.create({ const session = await this.stripe.billingPortal.sessions.create({
customer: input.customerId, customer: input.customerId,
return_url: input.returnUrl, return_url: input.returnUrl,
@ -246,9 +211,13 @@ export class StripeAdapter implements StripePort {
async constructWebhookEvent( async constructWebhookEvent(
payload: string | Buffer, payload: string | Buffer,
signature: string signature: string,
): Promise<StripeWebhookEvent> { ): Promise<StripeWebhookEvent> {
const event = this.stripe.webhooks.constructEvent(payload, signature, this.webhookSecret); const event = this.stripe.webhooks.constructEvent(
payload,
signature,
this.webhookSecret,
);
return { return {
type: event.type, type: event.type,

View File

@ -1,308 +1,413 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { getAllBookings, validateBankTransfer } from '@/lib/api/admin'; import { getAllBookings } from '@/lib/api/admin';
interface Booking { interface Booking {
id: string; id: string;
bookingNumber?: string | null; bookingNumber?: string;
type?: string; bookingId?: string;
status: string; type?: string;
origin?: string; status: string;
destination?: string; // CSV bookings use these fields
carrierName?: string; origin?: string;
containerType: string; destination?: string;
volumeCBM?: number; carrierName?: string;
weightKG?: number; // Regular bookings use these fields
palletCount?: number; originPort?: {
priceEUR?: number; code: string;
priceUSD?: number; name: string;
primaryCurrency?: string; };
createdAt?: string; destinationPort?: {
requestedAt?: string; code: string;
updatedAt?: string; name: string;
organizationId?: string; };
userId?: string; carrier?: string;
} containerType: string;
quantity?: number;
export default function AdminBookingsPage() { price?: number;
const [bookings, setBookings] = useState<Booking[]>([]); primaryCurrency?: string;
const [loading, setLoading] = useState(true); totalPrice?: {
const [error, setError] = useState<string | null>(null); amount: number;
const [filterStatus, setFilterStatus] = useState('all'); currency: string;
const [searchTerm, setSearchTerm] = useState(''); };
const [validatingId, setValidatingId] = useState<string | null>(null); createdAt?: string;
updatedAt?: string;
useEffect(() => { requestedAt?: string;
fetchBookings(); organizationId?: string;
}, []); userId?: string;
}
const handleValidateTransfer = async (bookingId: string) => {
if (!window.confirm('Confirmer la réception du virement et activer ce booking ?')) return; export default function AdminBookingsPage() {
setValidatingId(bookingId); const [bookings, setBookings] = useState<Booking[]>([]);
try { const [loading, setLoading] = useState(true);
await validateBankTransfer(bookingId); const [error, setError] = useState<string | null>(null);
await fetchBookings(); const [selectedBooking, setSelectedBooking] = useState<Booking | null>(null);
} catch (err: any) { const [showDetailsModal, setShowDetailsModal] = useState(false);
setError(err.message || 'Erreur lors de la validation du virement'); const [filterStatus, setFilterStatus] = useState('all');
} finally { const [searchTerm, setSearchTerm] = useState('');
setValidatingId(null);
} // Helper function to get formatted quote number
}; const getQuoteNumber = (booking: Booking): string => {
if (booking.type === 'csv') {
const fetchBookings = async () => { return `#${booking.bookingId || booking.id.slice(0, 8).toUpperCase()}`;
try { }
setLoading(true); return booking.bookingNumber || `#${booking.id.slice(0, 8).toUpperCase()}`;
const response = await getAllBookings(); };
setBookings(response.bookings || []);
setError(null); useEffect(() => {
} catch (err: any) { fetchBookings();
setError(err.message || 'Impossible de charger les réservations'); }, []);
} finally {
setLoading(false); const fetchBookings = async () => {
} try {
}; setLoading(true);
const response = await getAllBookings();
const getStatusColor = (status: string) => { setBookings(response.bookings || []);
const colors: Record<string, string> = { setError(null);
pending_payment: 'bg-orange-100 text-orange-800', } catch (err: any) {
pending_bank_transfer: 'bg-amber-100 text-amber-900', setError(err.message || 'Failed to load bookings');
pending: 'bg-yellow-100 text-yellow-800', } finally {
accepted: 'bg-green-100 text-green-800', setLoading(false);
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 getStatusColor = (status: string) => {
}; const colors: Record<string, string> = {
draft: 'bg-gray-100 text-gray-800',
const getStatusLabel = (status: string) => { pending: 'bg-yellow-100 text-yellow-800',
const labels: Record<string, string> = { confirmed: 'bg-blue-100 text-blue-800',
PENDING_PAYMENT: 'Paiement en attente', in_transit: 'bg-purple-100 text-purple-800',
PENDING_BANK_TRANSFER: 'Virement à valider', delivered: 'bg-green-100 text-green-800',
PENDING: 'En attente transporteur', cancelled: 'bg-red-100 text-red-800',
ACCEPTED: 'Accepté', };
REJECTED: 'Rejeté', return colors[status.toLowerCase()] || 'bg-gray-100 text-gray-800';
CANCELLED: 'Annulé', };
};
return labels[status.toUpperCase()] || status; const filteredBookings = bookings
}; .filter(booking => filterStatus === 'all' || booking.status.toLowerCase() === filterStatus)
.filter(booking => {
const getShortId = (booking: Booking) => `#${booking.id.slice(0, 8).toUpperCase()}`; if (searchTerm === '') return true;
const searchLower = searchTerm.toLowerCase();
const filteredBookings = bookings const quoteNumber = getQuoteNumber(booking).toLowerCase();
.filter(booking => filterStatus === 'all' || booking.status.toLowerCase() === filterStatus) return (
.filter(booking => { quoteNumber.includes(searchLower) ||
if (searchTerm === '') return true; booking.bookingNumber?.toLowerCase().includes(searchLower) ||
const s = searchTerm.toLowerCase(); booking.carrier?.toLowerCase().includes(searchLower) ||
return ( booking.carrierName?.toLowerCase().includes(searchLower) ||
booking.bookingNumber?.toLowerCase().includes(s) || booking.origin?.toLowerCase().includes(searchLower) ||
booking.id.toLowerCase().includes(s) || booking.destination?.toLowerCase().includes(searchLower)
booking.carrierName?.toLowerCase().includes(s) || );
booking.origin?.toLowerCase().includes(s) || });
booking.destination?.toLowerCase().includes(s) ||
String(booking.palletCount || '').includes(s) || if (loading) {
String(booking.weightKG || '').includes(s) || return (
String(booking.volumeCBM || '').includes(s) || <div className="flex items-center justify-center h-96">
booking.containerType?.toLowerCase().includes(s) <div className="text-center">
); <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
}); <p className="mt-4 text-gray-600">Loading bookings...</p>
</div>
if (loading) { </div>
return ( );
<div className="flex items-center justify-center h-96"> }
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div> return (
<p className="mt-4 text-gray-600">Chargement des réservations...</p> <div className="space-y-6">
</div> {/* Header */}
</div> <div className="flex items-center justify-between">
); <div>
} <h1 className="text-2xl font-bold text-gray-900">Booking Management</h1>
<p className="mt-1 text-sm text-gray-500">
return ( View and manage all bookings across the platform
<div className="space-y-6"> </p>
{/* Header */} </div>
<div> </div>
<h1 className="text-2xl font-bold text-gray-900">Gestion des réservations</h1>
<p className="mt-1 text-sm text-gray-500"> {/* Filters */}
Toutes les réservations de la plateforme <div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
</p> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
</div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2">Search</label>
{/* Stats Cards */} <input
<div className="grid grid-cols-2 md:grid-cols-5 gap-4"> type="text"
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4"> placeholder="Search by booking number or carrier..."
<div className="text-xs text-gray-500 uppercase tracking-wide">Total</div> value={searchTerm}
<div className="text-2xl font-bold text-gray-900 mt-1">{bookings.length}</div> onChange={e => setSearchTerm(e.target.value)}
</div> 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"
<div className="bg-amber-50 rounded-lg shadow-sm border border-amber-200 p-4"> />
<div className="text-xs text-amber-700 uppercase tracking-wide">Virements à valider</div> </div>
<div className="text-2xl font-bold text-amber-700 mt-1"> <div>
{bookings.filter(b => b.status.toUpperCase() === 'PENDING_BANK_TRANSFER').length} <label className="block text-sm font-medium text-gray-700 mb-2">Status Filter</label>
</div> <select
</div> value={filterStatus}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4"> onChange={e => setFilterStatus(e.target.value)}
<div className="text-xs text-gray-500 uppercase tracking-wide">En attente transporteur</div> 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"
<div className="text-2xl font-bold text-yellow-600 mt-1"> >
{bookings.filter(b => b.status.toUpperCase() === 'PENDING').length} <option value="all">All Statuses</option>
</div> <option value="draft">Draft</option>
</div> <option value="pending">Pending</option>
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4"> <option value="confirmed">Confirmed</option>
<div className="text-xs text-gray-500 uppercase tracking-wide">Acceptées</div> <option value="in_transit">In Transit</option>
<div className="text-2xl font-bold text-green-600 mt-1"> <option value="delivered">Delivered</option>
{bookings.filter(b => b.status.toUpperCase() === 'ACCEPTED').length} <option value="cancelled">Cancelled</option>
</div> </select>
</div> </div>
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4"> </div>
<div className="text-xs text-gray-500 uppercase tracking-wide">Rejetées</div> </div>
<div className="text-2xl font-bold text-red-600 mt-1">
{bookings.filter(b => b.status.toUpperCase() === 'REJECTED').length} {/* Stats Cards */}
</div> <div className="grid grid-cols-1 md:grid-cols-4 gap-4">
</div> <div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
</div> <div className="text-sm text-gray-500">Total Réservations</div>
<div className="text-2xl font-bold text-gray-900">{bookings.length}</div>
{/* Filters */} </div>
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4"> <div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="text-sm text-gray-500">En Attente</div>
<div> <div className="text-2xl font-bold text-yellow-600">
<label className="block text-sm font-medium text-gray-700 mb-1">Recherche</label> {bookings.filter(b => b.status.toUpperCase() === 'PENDING').length}
<input </div>
type="text" </div>
placeholder="N° booking, transporteur, route, palettes, poids, CBM..." <div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
value={searchTerm} <div className="text-sm text-gray-500">Acceptées</div>
onChange={e => setSearchTerm(e.target.value)} <div className="text-2xl font-bold text-green-600">
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" {bookings.filter(b => b.status.toUpperCase() === 'ACCEPTED').length}
/> </div>
</div> </div>
<div> <div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<label className="block text-sm font-medium text-gray-700 mb-1">Statut</label> <div className="text-sm text-gray-500">Rejetées</div>
<select <div className="text-2xl font-bold text-red-600">
value={filterStatus} {bookings.filter(b => b.status.toUpperCase() === 'REJECTED').length}
onChange={e => setFilterStatus(e.target.value)} </div>
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" </div>
> </div>
<option value="all">Tous les statuts</option>
<option value="pending_bank_transfer">Virement à valider</option> {/* Error Message */}
<option value="pending_payment">Paiement en attente</option> {error && (
<option value="pending">En attente transporteur</option> <div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg">
<option value="accepted">Accepté</option> {error}
<option value="rejected">Rejeté</option> </div>
<option value="cancelled">Annulé</option> )}
</select>
</div> {/* Bookings Table */}
</div> <div className="bg-white rounded-lg shadow overflow-hidden">
</div> <table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
{/* Error Message */} <tr>
{error && ( <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg text-sm"> Numéro de devis
{error} </th>
</div> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
)} Route
</th>
{/* Bookings Table */} <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<div className="bg-white rounded-lg shadow overflow-hidden"> Transporteur
<div className="overflow-x-auto"> </th>
<table className="min-w-full divide-y divide-gray-200"> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<thead className="bg-gray-50"> Conteneur
<tr> </th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
N° Booking Statut
</th> </th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Route Prix
</th> </th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Cargo Actions
</th> </th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> </tr>
Transporteur </thead>
</th> <tbody className="bg-white divide-y divide-gray-200">
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> {filteredBookings.map(booking => (
Statut <tr key={booking.id} className="hover:bg-gray-50">
</th> <td className="px-6 py-4 whitespace-nowrap">
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <div className="text-sm font-medium text-gray-900">
Date {getQuoteNumber(booking)}
</th> </div>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider"> <div className="text-xs text-gray-500">
Actions {new Date(booking.createdAt || booking.requestedAt || '').toLocaleDateString()}
</th> </div>
</tr> </td>
</thead> <td className="px-6 py-4 whitespace-nowrap">
<tbody className="bg-white divide-y divide-gray-200"> <div className="text-sm text-gray-900">
{filteredBookings.length === 0 ? ( {booking.originPort ? `${booking.originPort.code}${booking.destinationPort?.code}` : `${booking.origin}${booking.destination}`}
<tr> </div>
<td colSpan={7} className="px-4 py-8 text-center text-sm text-gray-500"> <div className="text-xs text-gray-500">
Aucune réservation trouvée {booking.originPort ? `${booking.originPort.name}${booking.destinationPort?.name}` : ''}
</td> </div>
</tr> </td>
) : ( <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
filteredBookings.map(booking => ( {booking.carrier || booking.carrierName || 'N/A'}
<tr key={booking.id} className="hover:bg-gray-50"> </td>
{/* N° Booking */} <td className="px-6 py-4 whitespace-nowrap">
<td className="px-4 py-4 whitespace-nowrap"> <div className="text-sm text-gray-900">{booking.containerType}</div>
{booking.bookingNumber && ( <div className="text-xs text-gray-500">
<div className="text-sm font-semibold text-gray-900">{booking.bookingNumber}</div> {booking.quantity ? `Qty: ${booking.quantity}` : ''}
)} </div>
<div className="text-xs text-gray-400 font-mono">{getShortId(booking)}</div> </td>
</td> <td className="px-6 py-4 whitespace-nowrap">
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(booking.status)}`}>
{/* Route */} {booking.status}
<td className="px-4 py-4 whitespace-nowrap"> </span>
<div className="text-sm font-medium text-gray-900"> </td>
{booking.origin} {booking.destination} <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
</div> {booking.totalPrice
</td> ? `${booking.totalPrice.amount.toLocaleString()} ${booking.totalPrice.currency}`
: booking.price
{/* Cargo */} ? `${booking.price.toLocaleString()} ${booking.primaryCurrency || 'USD'}`
<td className="px-4 py-4 whitespace-nowrap"> : 'N/A'
<div className="text-sm text-gray-900"> }
{booking.containerType} </td>
{booking.palletCount != null && ( <td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<span className="ml-1 text-gray-500">· {booking.palletCount} pal.</span> <button
)} onClick={() => {
</div> setSelectedBooking(booking);
<div className="text-xs text-gray-500 space-x-2"> setShowDetailsModal(true);
{booking.weightKG != null && <span>{booking.weightKG.toLocaleString()} kg</span>} }}
{booking.volumeCBM != null && <span>{booking.volumeCBM} CBM</span>} className="text-blue-600 hover:text-blue-900"
</div> >
</td> View Details
</button>
{/* Transporteur */} </td>
<td className="px-4 py-4 whitespace-nowrap text-sm text-gray-900"> </tr>
{booking.carrierName || '—'} ))}
</td> </tbody>
</table>
{/* Statut */} </div>
<td className="px-4 py-4 whitespace-nowrap">
<span className={`px-2 py-0.5 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(booking.status)}`}> {/* Details Modal */}
{getStatusLabel(booking.status)} {showDetailsModal && selectedBooking && (
</span> <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 overflow-y-auto">
</td> <div className="bg-white rounded-lg p-6 max-w-2xl w-full m-4">
<div className="flex items-center justify-between mb-6">
{/* Date */} <h2 className="text-xl font-bold">Booking Details</h2>
<td className="px-4 py-4 whitespace-nowrap text-sm text-gray-500"> <button
{new Date(booking.requestedAt || booking.createdAt || '').toLocaleDateString('fr-FR')} onClick={() => {
</td> setShowDetailsModal(false);
setSelectedBooking(null);
{/* Actions */} }}
<td className="px-4 py-4 whitespace-nowrap text-right text-sm"> className="text-gray-400 hover:text-gray-600"
{booking.status.toUpperCase() === 'PENDING_BANK_TRANSFER' && ( >
<button <svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
onClick={() => handleValidateTransfer(booking.id)} <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
disabled={validatingId === booking.id} </svg>
className="px-3 py-1 bg-green-600 text-white text-xs font-semibold rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed" </button>
> </div>
{validatingId === booking.id ? '...' : '✓ Valider virement'}
</button> <div className="space-y-4">
)} <div className="grid grid-cols-2 gap-4">
</td> <div>
</tr> <label className="block text-sm font-medium text-gray-500">Numéro de devis</label>
)) <div className="mt-1 text-lg font-semibold">
)} {getQuoteNumber(selectedBooking)}
</tbody> </div>
</table> </div>
</div> <div>
</div> <label className="block text-sm font-medium text-gray-500">Statut</label>
</div> <span className={`mt-1 inline-block px-3 py-1 text-sm font-semibold rounded-full ${getStatusColor(selectedBooking.status)}`}>
); {selectedBooking.status}
} </span>
</div>
</div>
<div className="border-t pt-4">
<h3 className="text-sm font-medium text-gray-900 mb-3">Route Information</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-500">Origin</label>
<div className="mt-1">
{selectedBooking.originPort ? (
<>
<div className="font-semibold">{selectedBooking.originPort.code}</div>
<div className="text-sm text-gray-600">{selectedBooking.originPort.name}</div>
</>
) : (
<div className="font-semibold">{selectedBooking.origin}</div>
)}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-500">Destination</label>
<div className="mt-1">
{selectedBooking.destinationPort ? (
<>
<div className="font-semibold">{selectedBooking.destinationPort.code}</div>
<div className="text-sm text-gray-600">{selectedBooking.destinationPort.name}</div>
</>
) : (
<div className="font-semibold">{selectedBooking.destination}</div>
)}
</div>
</div>
</div>
</div>
<div className="border-t pt-4">
<h3 className="text-sm font-medium text-gray-900 mb-3">Shipping Details</h3>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-500">Carrier</label>
<div className="mt-1 font-semibold">
{selectedBooking.carrier || selectedBooking.carrierName || 'N/A'}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-500">Container Type</label>
<div className="mt-1 font-semibold">{selectedBooking.containerType}</div>
</div>
{selectedBooking.quantity && (
<div>
<label className="block text-sm font-medium text-gray-500">Quantity</label>
<div className="mt-1 font-semibold">{selectedBooking.quantity}</div>
</div>
)}
</div>
</div>
<div className="border-t pt-4">
<h3 className="text-sm font-medium text-gray-900 mb-3">Pricing</h3>
<div className="text-2xl font-bold text-blue-600">
{selectedBooking.totalPrice
? `${selectedBooking.totalPrice.amount.toLocaleString()} ${selectedBooking.totalPrice.currency}`
: selectedBooking.price
? `${selectedBooking.price.toLocaleString()} ${selectedBooking.primaryCurrency || 'USD'}`
: 'N/A'
}
</div>
</div>
<div className="border-t pt-4">
<h3 className="text-sm font-medium text-gray-900 mb-3">Timeline</h3>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<label className="block text-gray-500">Created</label>
<div className="mt-1">
{new Date(selectedBooking.createdAt || selectedBooking.requestedAt || '').toLocaleString()}
</div>
</div>
{selectedBooking.updatedAt && (
<div>
<label className="block text-gray-500">Last Updated</label>
<div className="mt-1">{new Date(selectedBooking.updatedAt).toLocaleString()}</div>
</div>
)}
</div>
</div>
</div>
<div className="flex justify-end space-x-2 mt-6 pt-4 border-t">
<button
onClick={() => {
setShowDetailsModal(false);
setSelectedBooking(null);
}}
className="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50"
>
Close
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -1,7 +1,7 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { getAllOrganizations, verifySiret, approveSiret, rejectSiret } from '@/lib/api/admin'; import { getAllOrganizations } from '@/lib/api/admin';
import { createOrganization, updateOrganization } from '@/lib/api/organizations'; import { createOrganization, updateOrganization } from '@/lib/api/organizations';
interface Organization { interface Organization {
@ -10,9 +10,6 @@ interface Organization {
type: string; type: string;
scac?: string; scac?: string;
siren?: string; siren?: string;
siret?: string;
siretVerified?: boolean;
statusBadge?: string;
eori?: string; eori?: string;
contact_phone?: string; contact_phone?: string;
contact_email?: string; contact_email?: string;
@ -35,7 +32,6 @@ export default function AdminOrganizationsPage() {
const [selectedOrg, setSelectedOrg] = useState<Organization | null>(null); const [selectedOrg, setSelectedOrg] = useState<Organization | null>(null);
const [showCreateModal, setShowCreateModal] = useState(false); const [showCreateModal, setShowCreateModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false); const [showEditModal, setShowEditModal] = useState(false);
const [verifyingId, setVerifyingId] = useState<string | null>(null);
// Form state // Form state
const [formData, setFormData] = useState<{ const [formData, setFormData] = useState<{
@ -43,7 +39,6 @@ export default function AdminOrganizationsPage() {
type: string; type: string;
scac: string; scac: string;
siren: string; siren: string;
siret: string;
eori: string; eori: string;
contact_phone: string; contact_phone: string;
contact_email: string; contact_email: string;
@ -60,7 +55,6 @@ export default function AdminOrganizationsPage() {
type: 'FREIGHT_FORWARDER', type: 'FREIGHT_FORWARDER',
scac: '', scac: '',
siren: '', siren: '',
siret: '',
eori: '', eori: '',
contact_phone: '', contact_phone: '',
contact_email: '', contact_email: '',
@ -136,7 +130,6 @@ export default function AdminOrganizationsPage() {
type: 'FREIGHT_FORWARDER', type: 'FREIGHT_FORWARDER',
scac: '', scac: '',
siren: '', siren: '',
siret: '',
eori: '', eori: '',
contact_phone: '', contact_phone: '',
contact_email: '', contact_email: '',
@ -151,51 +144,6 @@ 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) => { const openEditModal = (org: Organization) => {
setSelectedOrg(org); setSelectedOrg(org);
setFormData({ setFormData({
@ -203,7 +151,6 @@ export default function AdminOrganizationsPage() {
type: org.type, type: org.type,
scac: org.scac || '', scac: org.scac || '',
siren: org.siren || '', siren: org.siren || '',
siret: org.siret || '',
eori: org.eori || '', eori: org.eori || '',
contact_phone: org.contact_phone || '', contact_phone: org.contact_phone || '',
contact_email: org.contact_email || '', contact_email: org.contact_email || '',
@ -282,25 +229,6 @@ export default function AdminOrganizationsPage() {
<span className="font-medium">SIREN:</span> {org.siren} <span className="font-medium">SIREN:</span> {org.siren}
</div> </div>
)} )}
<div className="flex items-center gap-2">
<span className="font-medium">SIRET:</span>
{org.siret ? (
<>
<span>{org.siret}</span>
{org.siretVerified ? (
<span className="inline-flex items-center px-2 py-0.5 text-xs font-semibold rounded-full bg-green-100 text-green-800">
Verifie
</span>
) : (
<span className="inline-flex items-center px-2 py-0.5 text-xs font-semibold rounded-full bg-yellow-100 text-yellow-800">
Non verifie
</span>
)}
</>
) : (
<span className="text-gray-400">Non renseigne</span>
)}
</div>
{org.contact_email && ( {org.contact_email && (
<div> <div>
<span className="font-medium">Email:</span> {org.contact_email} <span className="font-medium">Email:</span> {org.contact_email}
@ -311,45 +239,13 @@ export default function AdminOrganizationsPage() {
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="flex space-x-2">
<div className="flex space-x-2"> <button
<button onClick={() => openEditModal(org)}
onClick={() => openEditModal(org)} className="flex-1 px-3 py-2 bg-blue-50 text-blue-700 rounded-md hover:bg-blue-100 transition-colors text-sm font-medium"
className="flex-1 px-3 py-2 bg-blue-50 text-blue-700 rounded-md hover:bg-blue-100 transition-colors text-sm font-medium" >
> Edit
Edit </button>
</button>
{org.siret && !org.siretVerified && (
<button
onClick={() => handleVerifySiret(org.id)}
disabled={verifyingId === org.id}
className="flex-1 px-3 py-2 bg-purple-50 text-purple-700 rounded-md hover:bg-purple-100 transition-colors text-sm font-medium disabled:opacity-50"
>
{verifyingId === org.id ? '...' : 'Verifier API'}
</button>
)}
</div>
{(org.siret || org.siren) && (
<div className="flex space-x-2">
{!org.siretVerified ? (
<button
onClick={() => handleApproveSiret(org.id)}
disabled={verifyingId === org.id}
className="flex-1 px-3 py-2 bg-green-50 text-green-700 rounded-md hover:bg-green-100 transition-colors text-sm font-medium disabled:opacity-50"
>
Approuver SIRET
</button>
) : (
<button
onClick={() => handleRejectSiret(org.id)}
disabled={verifyingId === org.id}
className="flex-1 px-3 py-2 bg-red-50 text-red-700 rounded-md hover:bg-red-100 transition-colors text-sm font-medium disabled:opacity-50"
>
Rejeter SIRET
</button>
)}
</div>
)}
</div> </div>
</div> </div>
))} ))}
@ -413,18 +309,6 @@ export default function AdminOrganizationsPage() {
/> />
</div> </div>
<div>
<label className="block text-sm font-medium text-gray-700">SIRET (14 chiffres)</label>
<input
type="text"
maxLength={14}
value={formData.siret}
onChange={e => 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"
/>
</div>
<div> <div>
<label className="block text-sm font-medium text-gray-700">EORI</label> <label className="block text-sm font-medium text-gray-700">EORI</label>
<input <input

View File

@ -1,437 +0,0 @@
/**
* Commission Payment Page
*
* 2-column layout:
* - Left: payment method selector + action
* - Right: booking summary
*/
'use client';
import { useState, useEffect } from 'react';
import { useRouter, useParams } from 'next/navigation';
import {
CreditCard,
Building2,
ArrowLeft,
Loader2,
AlertTriangle,
CheckCircle,
Copy,
Clock,
} from 'lucide-react';
import { getCsvBooking, payBookingCommission, declareBankTransfer } from '@/lib/api/bookings';
interface BookingData {
id: string;
bookingNumber?: string;
carrierName: string;
carrierEmail: string;
origin: string;
destination: string;
volumeCBM: number;
weightKG: number;
palletCount: number;
priceEUR: number;
priceUSD: number;
primaryCurrency: string;
transitDays: number;
containerType: string;
status: string;
commissionRate?: number;
commissionAmountEur?: number;
}
type PaymentMethod = 'card' | 'transfer' | null;
const BANK_DETAILS = {
beneficiary: 'XPEDITIS SAS',
iban: 'FR76 XXXX XXXX XXXX XXXX XXXX XXX',
bic: 'XXXXXXXX',
};
export default function PayCommissionPage() {
const router = useRouter();
const params = useParams();
const bookingId = params.id as string;
const [booking, setBooking] = useState<BookingData | null>(null);
const [loading, setLoading] = useState(true);
const [paying, setPaying] = useState(false);
const [declaring, setDeclaring] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedMethod, setSelectedMethod] = useState<PaymentMethod>(null);
const [copied, setCopied] = useState<string | null>(null);
useEffect(() => {
async function fetchBooking() {
try {
const data = await getCsvBooking(bookingId);
setBooking(data as any);
if (data.status !== 'PENDING_PAYMENT') {
router.replace('/dashboard/bookings');
}
} catch (err) {
setError('Impossible de charger les détails du booking');
} finally {
setLoading(false);
}
}
if (bookingId) fetchBooking();
}, [bookingId, router]);
const handlePayByCard = async () => {
setPaying(true);
setError(null);
try {
const result = await payBookingCommission(bookingId);
window.location.href = result.sessionUrl;
} catch (err) {
setError(err instanceof Error ? err.message : 'Erreur lors de la création du paiement');
setPaying(false);
}
};
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 (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-blue-50">
<div className="flex items-center space-x-3">
<Loader2 className="h-6 w-6 animate-spin text-blue-600" />
<span className="text-gray-600">Chargement...</span>
</div>
</div>
);
}
if (error && !booking) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-blue-50">
<div className="bg-white rounded-xl shadow-md p-8 max-w-md">
<AlertTriangle className="h-12 w-12 text-red-500 mx-auto mb-4" />
<p className="text-center text-gray-700">{error}</p>
<button
onClick={() => router.push('/dashboard/bookings')}
className="mt-4 w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Retour aux bookings
</button>
</div>
</div>
);
}
if (!booking) return null;
const commissionAmount = booking.commissionAmountEur || 0;
const commissionRate = booking.commissionRate || 0;
const reference = booking.bookingNumber || booking.id.slice(0, 8).toUpperCase();
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-blue-50 py-10 px-4">
<div className="max-w-5xl mx-auto">
{/* Back button */}
<button
onClick={() => router.push('/dashboard/bookings')}
className="mb-6 flex items-center text-blue-600 hover:text-blue-800 font-medium"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Retour aux bookings
</button>
<h1 className="text-2xl font-bold text-gray-900 mb-1">Paiement de la commission</h1>
<p className="text-gray-500 mb-8">
Finalisez votre booking en réglant la commission de service
</p>
{error && (
<div className="mb-6 bg-red-50 border border-red-200 rounded-lg p-4 flex items-start space-x-3">
<AlertTriangle className="h-5 w-5 text-red-500 flex-shrink-0 mt-0.5" />
<p className="text-red-700 text-sm">{error}</p>
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
{/* LEFT — Payment method selector */}
<div className="lg:col-span-3 space-y-4">
<h2 className="text-base font-semibold text-gray-700 uppercase tracking-wide">
Choisir le mode de paiement
</h2>
{/* Card option */}
<button
onClick={() => setSelectedMethod('card')}
className={`w-full text-left rounded-xl border-2 p-5 transition-all ${
selectedMethod === 'card'
? 'border-blue-500 bg-blue-50'
: 'border-gray-200 bg-white hover:border-gray-300'
}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div
className={`w-10 h-10 rounded-full flex items-center justify-center ${
selectedMethod === 'card' ? 'bg-blue-100' : 'bg-gray-100'
}`}
>
<CreditCard
className={`h-5 w-5 ${
selectedMethod === 'card' ? 'text-blue-600' : 'text-gray-500'
}`}
/>
</div>
<div>
<p className="font-semibold text-gray-900">Carte bancaire</p>
<p className="text-sm text-gray-500">Paiement immédiat via Stripe</p>
</div>
</div>
<div
className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${
selectedMethod === 'card' ? 'border-blue-500 bg-blue-500' : 'border-gray-300'
}`}
>
{selectedMethod === 'card' && (
<div className="w-2 h-2 rounded-full bg-white" />
)}
</div>
</div>
</button>
{/* Transfer option */}
<button
onClick={() => setSelectedMethod('transfer')}
className={`w-full text-left rounded-xl border-2 p-5 transition-all ${
selectedMethod === 'transfer'
? 'border-blue-500 bg-blue-50'
: 'border-gray-200 bg-white hover:border-gray-300'
}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div
className={`w-10 h-10 rounded-full flex items-center justify-center ${
selectedMethod === 'transfer' ? 'bg-blue-100' : 'bg-gray-100'
}`}
>
<Building2
className={`h-5 w-5 ${
selectedMethod === 'transfer' ? 'text-blue-600' : 'text-gray-500'
}`}
/>
</div>
<div>
<p className="font-semibold text-gray-900">Virement bancaire</p>
<p className="text-sm text-gray-500">Validation sous 13 jours ouvrables</p>
</div>
</div>
<div
className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${
selectedMethod === 'transfer'
? 'border-blue-500 bg-blue-500'
: 'border-gray-300'
}`}
>
{selectedMethod === 'transfer' && (
<div className="w-2 h-2 rounded-full bg-white" />
)}
</div>
</div>
</button>
{/* Card action */}
{selectedMethod === 'card' && (
<div className="bg-white rounded-xl border border-gray-200 p-5">
<p className="text-sm text-gray-600 mb-4">
Vous serez redirigé vers Stripe pour finaliser votre paiement en toute sécurité.
</p>
<button
onClick={handlePayByCard}
disabled={paying}
className="w-full py-3 bg-blue-600 text-white rounded-lg font-semibold hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed flex items-center justify-center space-x-2 transition-colors"
>
{paying ? (
<>
<Loader2 className="h-5 w-5 animate-spin" />
<span>Redirection vers Stripe...</span>
</>
) : (
<>
<CreditCard className="h-5 w-5" />
<span>Payer {formatPrice(commissionAmount, 'EUR')} par carte</span>
</>
)}
</button>
</div>
)}
{/* Transfer action */}
{selectedMethod === 'transfer' && (
<div className="bg-white rounded-xl border border-gray-200 p-5 space-y-4">
<p className="text-sm text-gray-600">
Effectuez le virement avec les coordonnées ci-dessous, puis cliquez sur
&ldquo;J&apos;ai effectué le virement&rdquo;.
</p>
{/* Bank details */}
<div className="bg-gray-50 rounded-lg divide-y divide-gray-200 text-sm">
{[
{ 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 }) => (
<div key={key} className="flex items-center justify-between px-4 py-3">
<span className="text-gray-500">{label}</span>
<div className="flex items-center space-x-2">
<span
className={`${mono ? 'font-mono' : ''} ${bold ? 'font-bold text-gray-900' : 'text-gray-800'}`}
>
{value}
</span>
{key !== 'amount' && (
<button
onClick={() => copyToClipboard(value, key)}
className="text-gray-400 hover:text-blue-600 transition-colors"
title="Copier"
>
{copied === key ? (
<CheckCircle className="h-4 w-4 text-green-500" />
) : (
<Copy className="h-4 w-4" />
)}
</button>
)}
</div>
</div>
))}
</div>
<div className="flex items-start space-x-2 text-xs text-amber-700 bg-amber-50 rounded-lg p-3">
<Clock className="h-4 w-4 flex-shrink-0 mt-0.5" />
<span>
Mentionnez impérativement la référence <strong>{reference}</strong> dans le
libellé du virement.
</span>
</div>
<button
onClick={handleDeclareTransfer}
disabled={declaring}
className="w-full py-3 bg-green-600 text-white rounded-lg font-semibold hover:bg-green-700 disabled:bg-gray-400 disabled:cursor-not-allowed flex items-center justify-center space-x-2 transition-colors"
>
{declaring ? (
<>
<Loader2 className="h-5 w-5 animate-spin" />
<span>Enregistrement...</span>
</>
) : (
<>
<CheckCircle className="h-5 w-5" />
<span>J&apos;ai effectué le virement</span>
</>
)}
</button>
</div>
)}
{/* Placeholder when no method selected */}
{selectedMethod === null && (
<div className="bg-white rounded-xl border-2 border-dashed border-gray-200 p-6 text-center text-gray-400 text-sm">
Sélectionnez un mode de paiement ci-dessus
</div>
)}
</div>
{/* RIGHT — Booking summary */}
<div className="lg:col-span-2 space-y-4">
<h2 className="text-base font-semibold text-gray-700 uppercase tracking-wide">
Récapitulatif
</h2>
<div className="bg-white rounded-xl border border-gray-200 p-5 space-y-4">
{booking.bookingNumber && (
<div className="flex justify-between text-sm">
<span className="text-gray-500">Numéro</span>
<span className="font-semibold text-gray-900">{booking.bookingNumber}</span>
</div>
)}
<div className="flex justify-between text-sm">
<span className="text-gray-500">Transporteur</span>
<span className="font-semibold text-gray-900 text-right max-w-[55%]">
{booking.carrierName}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-500">Trajet</span>
<span className="font-semibold text-gray-900">
{booking.origin} {booking.destination}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-500">Volume / Poids</span>
<span className="font-semibold text-gray-900">
{booking.volumeCBM} CBM · {booking.weightKG} kg
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-500">Transit</span>
<span className="font-semibold text-gray-900">{booking.transitDays} jours</span>
</div>
<div className="border-t pt-3 flex justify-between text-sm">
<span className="text-gray-500">Prix transport</span>
<span className="font-bold text-gray-900">
{formatPrice(booking.priceEUR, 'EUR')}
</span>
</div>
</div>
{/* Commission box */}
<div className="bg-blue-600 rounded-xl p-5 text-white">
<p className="text-sm text-blue-100 mb-1">
Commission ({commissionRate}% du prix transport)
</p>
<p className="text-3xl font-bold">{formatPrice(commissionAmount, 'EUR')}</p>
<p className="text-xs text-blue-200 mt-1">
{formatPrice(booking.priceEUR, 'EUR')} × {commissionRate}%
</p>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-4 flex items-start space-x-3">
<CheckCircle className="h-4 w-4 text-green-500 mt-0.5 flex-shrink-0" />
<p className="text-xs text-gray-500">
Après validation du paiement, votre demande est envoyée au transporteur (
{booking.carrierEmail}). Vous serez notifié de sa réponse.
</p>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -1,147 +0,0 @@
/**
* Payment Success Page
*
* Displayed after successful Stripe payment. Confirms the payment and activates the booking.
*/
'use client';
import { useState, useEffect, useRef } from 'react';
import { useRouter, useParams, useSearchParams } from 'next/navigation';
import { CheckCircle, Loader2, AlertTriangle, Mail, ArrowRight } from 'lucide-react';
import { confirmBookingPayment } from '@/lib/api/bookings';
export default function PaymentSuccessPage() {
const router = useRouter();
const params = useParams();
const searchParams = useSearchParams();
const bookingId = params.id as string;
const sessionId = searchParams.get('session_id');
const [status, setStatus] = useState<'confirming' | 'success' | 'error'>('confirming');
const [error, setError] = useState<string | null>(null);
const confirmedRef = useRef(false);
useEffect(() => {
async function confirm() {
if (!sessionId || !bookingId || confirmedRef.current) return;
confirmedRef.current = true;
try {
await confirmBookingPayment(bookingId, sessionId);
setStatus('success');
} catch (err) {
console.error('Payment confirmation error:', err);
setError(
err instanceof Error ? err.message : 'Erreur lors de la confirmation du paiement'
);
setStatus('error');
}
}
confirm();
}, [bookingId, sessionId]);
if (!sessionId) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100">
<div className="bg-white rounded-lg shadow-md p-8 max-w-md text-center">
<AlertTriangle className="h-12 w-12 text-red-500 mx-auto mb-4" />
<h2 className="text-xl font-bold text-gray-900 mb-2">Session invalide</h2>
<p className="text-gray-600 mb-4">Aucune session de paiement trouvee.</p>
<button
onClick={() => router.push('/dashboard/bookings')}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Retour aux bookings
</button>
</div>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 px-4">
<div className="bg-white rounded-lg shadow-lg p-8 max-w-md w-full text-center">
{status === 'confirming' && (
<>
<Loader2 className="h-16 w-16 animate-spin text-blue-600 mx-auto mb-6" />
<h2 className="text-xl font-bold text-gray-900 mb-2">Confirmation du paiement...</h2>
<p className="text-gray-600">
Veuillez patienter pendant que nous verifions votre paiement et activons votre booking.
</p>
</>
)}
{status === 'success' && (
<>
<div className="mb-6">
<div className="w-20 h-20 bg-green-100 rounded-full flex items-center justify-center mx-auto">
<CheckCircle className="h-12 w-12 text-green-600" />
</div>
</div>
<h2 className="text-2xl font-bold text-gray-900 mb-3">Paiement confirme !</h2>
<p className="text-gray-600 mb-6">
Votre commission a ete payee avec succes. Un email a ete envoye au transporteur avec votre demande de booking.
</p>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
<div className="flex items-center justify-center space-x-2 text-blue-700">
<Mail className="h-5 w-5" />
<span className="text-sm font-medium">
Email envoye au transporteur
</span>
</div>
<p className="text-xs text-blue-600 mt-1">
Vous recevrez une notification des que le transporteur repond (sous 7 jours max)
</p>
</div>
<button
onClick={() => router.push('/dashboard/bookings')}
className="w-full px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-semibold flex items-center justify-center"
>
Voir mes bookings
<ArrowRight className="h-4 w-4 ml-2" />
</button>
</>
)}
{status === 'error' && (
<>
<AlertTriangle className="h-16 w-16 text-red-500 mx-auto mb-6" />
<h2 className="text-xl font-bold text-gray-900 mb-2">Erreur de confirmation</h2>
<p className="text-gray-600 mb-2">{error}</p>
<p className="text-sm text-gray-500 mb-6">
Si votre paiement a ete debite, contactez le support. Votre booking sera active manuellement.
</p>
<div className="space-y-3">
<button
onClick={() => {
confirmedRef.current = false;
setStatus('confirming');
setError(null);
confirmBookingPayment(bookingId, sessionId!)
.then(() => setStatus('success'))
.catch(err => {
setError(err instanceof Error ? err.message : 'Erreur');
setStatus('error');
});
}}
className="w-full px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Reessayer
</button>
<button
onClick={() => router.push('/dashboard/bookings')}
className="w-full px-6 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300"
>
Retour aux bookings
</button>
</div>
</>
)}
</div>
</div>
);
}

View File

@ -177,8 +177,8 @@ function NewBookingPageContent() {
// Send to API using client function // Send to API using client function
const result = await createCsvBooking(formDataToSend); const result = await createCsvBooking(formDataToSend);
// Redirect to commission payment page // Redirect to success page
router.push(`/dashboard/booking/${result.id}/pay`); router.push(`/dashboard/bookings?success=true&id=${result.id}`);
} catch (err) { } catch (err) {
console.error('Booking creation error:', err); console.error('Booking creation error:', err);
setError(err instanceof Error ? err.message : 'Une erreur est survenue'); setError(err instanceof Error ? err.message : 'Une erreur est survenue');

View File

@ -6,31 +6,22 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { listBookings, listCsvBookings } from '@/lib/api'; import { listBookings, listCsvBookings } from '@/lib/api';
import Link from 'next/link'; import Link from 'next/link';
import { Plus, Clock } from 'lucide-react'; import { Plus } from 'lucide-react';
import ExportButton from '@/components/ExportButton'; import ExportButton from '@/components/ExportButton';
import { useSearchParams } from 'next/navigation';
type SearchType = 'pallets' | 'weight' | 'route' | 'status' | 'date' | 'quote'; type SearchType = 'pallets' | 'weight' | 'route' | 'status' | 'date' | 'quote';
export default function BookingsListPage() { export default function BookingsListPage() {
const searchParams = useSearchParams();
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [searchType, setSearchType] = useState<SearchType>('route'); const [searchType, setSearchType] = useState<SearchType>('route');
const [statusFilter, setStatusFilter] = useState(''); const [statusFilter, setStatusFilter] = useState('');
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [showTransferBanner, setShowTransferBanner] = useState(false);
const ITEMS_PER_PAGE = 20; 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) // Fetch CSV bookings (fetch all for client-side filtering and pagination)
const { data: csvData, isLoading, error: csvError } = useQuery({ const { data: csvData, isLoading, error: csvError } = useQuery({
queryKey: ['csv-bookings'], queryKey: ['csv-bookings'],
@ -151,21 +142,6 @@ export default function BookingsListPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Bank transfer declared banner */}
{showTransferBanner && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 flex items-start justify-between">
<div className="flex items-start space-x-3">
<Clock className="h-5 w-5 text-amber-600 flex-shrink-0 mt-0.5" />
<div>
<p className="font-medium text-amber-800">Virement déclaré</p>
<p className="text-sm text-amber-700 mt-0.5">
Votre virement a é enregistré. Un administrateur va vérifier la réception et activer votre booking. Vous serez notifié dès la validation.
</p>
</div>
</div>
<button onClick={() => setShowTransferBanner(false)} className="text-amber-500 hover:text-amber-700 ml-4 flex-shrink-0"></button>
</div>
)}
{/* Header */} {/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>

View File

@ -22,15 +22,10 @@ import {
Building2, Building2,
Users, Users,
LogOut, LogOut,
Lock,
} from 'lucide-react'; } from 'lucide-react';
import { useSubscription } from '@/lib/context/subscription-context';
import StatusBadge from '@/components/ui/StatusBadge';
import type { PlanFeature } from '@/lib/api/subscriptions';
export default function DashboardLayout({ children }: { children: React.ReactNode }) { export default function DashboardLayout({ children }: { children: React.ReactNode }) {
const { user, logout, loading, isAuthenticated } = useAuth(); const { user, logout, loading, isAuthenticated } = useAuth();
const { hasFeature, subscription } = useSubscription();
const pathname = usePathname(); const pathname = usePathname();
const router = useRouter(); const router = useRouter();
const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false);
@ -53,16 +48,16 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
return null; return null;
} }
const navigation: Array<{ name: string; href: string; icon: any; requiredFeature?: PlanFeature }> = [ const navigation = [
{ name: 'Tableau de bord', href: '/dashboard', icon: BarChart3, requiredFeature: 'dashboard' }, { name: 'Tableau de bord', href: '/dashboard', icon: BarChart3 },
{ name: 'Réservations', href: '/dashboard/bookings', icon: Package }, { name: 'Réservations', href: '/dashboard/bookings', icon: Package },
{ name: 'Documents', href: '/dashboard/documents', icon: FileText }, { name: 'Documents', href: '/dashboard/documents', icon: FileText },
{ name: 'Suivi', href: '/dashboard/track-trace', icon: Search, requiredFeature: 'dashboard' }, { name: 'Suivi', href: '/dashboard/track-trace', icon: Search },
{ name: 'Wiki Maritime', href: '/dashboard/wiki', icon: BookOpen, requiredFeature: 'wiki' }, { name: 'Wiki Maritime', href: '/dashboard/wiki', icon: BookOpen },
{ name: 'Organisation', href: '/dashboard/settings/organization', icon: Building2 }, { name: 'Organisation', href: '/dashboard/settings/organization', icon: Building2 },
// ADMIN and MANAGER only navigation items // ADMIN and MANAGER only navigation items
...(user?.role === 'ADMIN' || user?.role === 'MANAGER' ? [ ...(user?.role === 'ADMIN' || user?.role === 'MANAGER' ? [
{ name: 'Utilisateurs', href: '/dashboard/settings/users', icon: Users, requiredFeature: 'user_management' as PlanFeature }, { name: 'Utilisateurs', href: '/dashboard/settings/users', icon: Users },
] : []), ] : []),
]; ];
@ -119,26 +114,20 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
{/* Navigation */} {/* Navigation */}
<nav className="flex-1 px-4 py-6 space-y-2 overflow-y-auto"> <nav className="flex-1 px-4 py-6 space-y-2 overflow-y-auto">
{navigation.map(item => { {navigation.map(item => (
const locked = item.requiredFeature && !hasFeature(item.requiredFeature); <Link
return ( key={item.name}
<Link href={item.href}
key={item.name} className={`flex items-center px-4 py-3 text-sm font-medium rounded-lg transition-colors ${
href={locked ? '/pricing' : item.href} isActive(item.href)
className={`flex items-center px-4 py-3 text-sm font-medium rounded-lg transition-colors ${ ? 'bg-blue-50 text-blue-700'
locked : 'text-gray-700 hover:bg-gray-100'
? 'text-gray-400 hover:bg-gray-50' }`}
: isActive(item.href) >
? 'bg-blue-50 text-blue-700' <item.icon className="mr-3 h-5 w-5" />
: 'text-gray-700 hover:bg-gray-100' {item.name}
}`} </Link>
> ))}
<item.icon className="mr-3 h-5 w-5" />
<span className="flex-1">{item.name}</span>
{locked && <Lock className="w-4 h-4 text-gray-300" />}
</Link>
);
})}
{/* Admin Panel - ADMIN role only */} {/* Admin Panel - ADMIN role only */}
{user?.role === 'ADMIN' && ( {user?.role === 'ADMIN' && (
@ -156,14 +145,9 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
{user?.lastName?.[0]} {user?.lastName?.[0]}
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5"> <p className="text-sm font-medium text-gray-900 truncate">
<p className="text-sm font-medium text-gray-900 truncate"> {user?.firstName} {user?.lastName}
{user?.firstName} {user?.lastName} </p>
</p>
{subscription?.planDetails?.statusBadge && subscription.planDetails.statusBadge !== 'none' && (
<StatusBadge badge={subscription.planDetails.statusBadge} size="sm" />
)}
</div>
<p className="text-xs text-gray-500 truncate">{user?.email}</p> <p className="text-xs text-gray-500 truncate">{user?.email}</p>
</div> </div>
</div> </div>

View File

@ -5,14 +5,12 @@
'use client'; 'use client';
import { useEffect } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { dashboardApi } from '@/lib/api'; import { dashboardApi } from '@/lib/api';
import Link from 'next/link'; import Link from 'next/link';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { useRouter } from 'next/navigation';
import { import {
Package, Package,
PackageCheck, PackageCheck,
@ -23,7 +21,6 @@ import {
Plus, Plus,
ArrowRight, ArrowRight,
} from 'lucide-react'; } from 'lucide-react';
import { useSubscription } from '@/lib/context/subscription-context';
import ExportButton from '@/components/ExportButton'; import ExportButton from '@/components/ExportButton';
import { import {
PieChart, PieChart,
@ -42,16 +39,6 @@ import {
} from 'recharts'; } from 'recharts';
export default function DashboardPage() { export default function DashboardPage() {
const router = useRouter();
const { hasFeature, loading: subLoading } = useSubscription();
// Redirect Bronze users (no dashboard feature) to bookings
useEffect(() => {
if (!subLoading && !hasFeature('dashboard')) {
router.replace('/dashboard/bookings');
}
}, [subLoading, hasFeature, router]);
// Fetch CSV booking KPIs // Fetch CSV booking KPIs
const { data: csvKpis, isLoading: csvKpisLoading } = useQuery({ const { data: csvKpis, isLoading: csvKpisLoading } = useQuery({
queryKey: ['dashboard', 'csv-booking-kpis'], queryKey: ['dashboard', 'csv-booking-kpis'],

View File

@ -85,8 +85,6 @@ export default function LandingPage() {
const isCtaInView = useInView(ctaRef, { once: true }); const isCtaInView = useInView(ctaRef, { once: true });
const isHowInView = useInView(howRef, { once: true, amount: 0.2 }); const isHowInView = useInView(howRef, { once: true, amount: 0.2 });
const [billingYearly, setBillingYearly] = useState(false);
const { scrollYProgress } = useScroll(); const { scrollYProgress } = useScroll();
const backgroundY = useTransform(scrollYProgress, [0, 1], ['0%', '50%']); const backgroundY = useTransform(scrollYProgress, [0, 1], ['0%', '50%']);
@ -187,120 +185,58 @@ export default function LandingPage() {
const pricingPlans = [ const pricingPlans = [
{ {
key: 'bronze', name: 'Starter',
name: 'Bronze', price: 'Gratuit',
badge: null, period: '',
monthlyPrice: 0, description: 'Idéal pour découvrir la plateforme',
yearlyPrice: 0,
yearlyMonthly: 0,
description: 'Pour découvrir la plateforme',
users: '1 utilisateur',
shipments: '12 expéditions / an',
commission: '5%',
support: 'Aucun support',
features: [ features: [
{ text: 'Réservations maritimes LCL', included: true }, { text: 'Jusqu\'à 5 bookings/mois', included: true },
{ text: 'Track & Trace conteneurs', included: true }, { text: 'Track & Trace illimité', included: true },
{ text: 'Tableau de bord', included: false }, { text: 'Wiki maritime complet', included: true },
{ text: 'Wiki maritime', included: false }, { text: 'Dashboard basique', included: true },
{ text: 'Gestion des utilisateurs', included: false }, { text: 'Support par email', included: true },
{ text: 'Export CSV', included: false }, { text: 'Gestion des documents', included: false },
{ text: 'Notifications temps réel', included: false },
{ text: 'Accès API', included: false }, { text: 'Accès API', included: false },
{ text: 'KAM dédié', included: false },
], ],
cta: 'Commencer gratuitement', cta: 'Commencer gratuitement',
ctaLink: '/register',
highlighted: false, highlighted: false,
accentColor: 'from-amber-600 to-yellow-500',
textAccent: 'text-amber-700',
badgeBg: 'bg-amber-100 text-amber-800',
}, },
{ {
key: 'silver', name: 'Professional',
name: 'Silver', price: '99€',
badge: 'Populaire', period: '/mois',
monthlyPrice: 249,
yearlyPrice: 2739,
yearlyMonthly: 228,
description: 'Pour les transitaires en croissance', description: 'Pour les transitaires en croissance',
users: 'Jusqu\'à 5 utilisateurs',
shipments: 'Expéditions illimitées',
commission: '3%',
support: 'Support par email',
features: [ features: [
{ text: 'Réservations maritimes LCL', included: true }, { text: 'Bookings illimités', included: true },
{ text: 'Track & Trace conteneurs', included: true }, { text: 'Track & Trace illimité', included: true },
{ text: 'Tableau de bord avancé', included: true },
{ text: 'Wiki maritime complet', included: true }, { text: 'Wiki maritime complet', included: true },
{ text: 'Gestion des utilisateurs', included: true }, { text: 'Dashboard avancé + KPIs', included: true },
{ text: 'Export CSV', included: true }, { text: 'Support prioritaire', included: true },
{ text: 'Gestion des documents', included: true },
{ text: 'Notifications temps réel', included: true },
{ text: 'Accès API', included: false }, { text: 'Accès API', included: false },
{ text: 'KAM dédié', included: false },
], ],
cta: 'Essai gratuit 14 jours', cta: 'Essai gratuit 14 jours',
ctaLink: '/register',
highlighted: true, highlighted: true,
accentColor: 'from-slate-400 to-slate-500',
textAccent: 'text-slate-600',
badgeBg: 'bg-slate-100 text-slate-700',
}, },
{ {
key: 'gold', name: 'Enterprise',
name: 'Gold', price: 'Sur mesure',
badge: null, period: '',
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: '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é', included: false },
],
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', description: 'Pour les grandes entreprises',
users: 'Utilisateurs illimités',
shipments: 'Expéditions illimitées',
commission: '1%',
support: 'Key Account Manager dédié',
features: [ features: [
{ text: 'Réservations maritimes LCL', included: true }, { text: 'Tout Professionnel +', 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: 'Accès API complet', included: true },
{ text: 'KAM dédié + Interface personnalisée', 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 },
], ],
cta: 'Nous contacter', cta: 'Contactez-nous',
ctaLink: '/contact',
highlighted: false, highlighted: false,
accentColor: 'from-brand-navy to-brand-turquoise',
textAccent: 'text-brand-turquoise',
badgeBg: 'bg-brand-navy/10 text-brand-navy',
}, },
]; ];
@ -719,198 +655,76 @@ export default function LandingPage() {
<section <section
ref={pricingRef} ref={pricingRef}
id="pricing" id="pricing"
className="py-20 lg:py-32 bg-gradient-to-b from-white to-gray-50" className="py-20 lg:py-32 bg-gradient-to-br from-gray-50 to-white"
> >
<div className="max-w-7xl mx-auto px-6 lg:px-8"> <div className="max-w-7xl mx-auto px-6 lg:px-8">
{/* Header */}
<motion.div <motion.div
initial={{ opacity: 0, y: 30 }} initial={{ opacity: 0, y: 30 }}
animate={isPricingInView ? { opacity: 1, y: 0 } : {}} animate={isPricingInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.8 }} transition={{ duration: 0.8 }}
className="text-center mb-12" className="text-center mb-16"
> >
<span className="inline-block bg-brand-turquoise/10 text-brand-turquoise text-sm font-semibold px-4 py-1.5 rounded-full mb-4 uppercase tracking-wide">
Tarifs
</span>
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4"> <h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">
Des plans adaptés à votre activité Tarifs simples et transparents
</h2> </h2>
<p className="text-xl text-gray-600 max-w-2xl mx-auto"> <p className="text-xl text-gray-600 max-w-2xl mx-auto">
De l'accès découverte au partenariat sur mesure évoluez à tout moment. Choisissez le plan adapté à vos besoins. Évoluez à tout moment.
</p> </p>
</motion.div> </motion.div>
{/* Billing Toggle */}
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={isPricingInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.6, delay: 0.2 }}
className="flex items-center justify-center gap-4 mb-12"
>
<span className={`text-sm font-medium ${!billingYearly ? 'text-brand-navy' : 'text-gray-400'}`}>
Mensuel
</span>
<button
onClick={() => setBillingYearly(v => !v)}
className={`relative inline-flex h-7 w-14 items-center rounded-full transition-colors focus:outline-none ${
billingYearly ? 'bg-brand-turquoise' : 'bg-gray-300'
}`}
>
<span
className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition-transform ${
billingYearly ? 'translate-x-8' : 'translate-x-1'
}`}
/>
</button>
<span className={`text-sm font-medium ${billingYearly ? 'text-brand-navy' : 'text-gray-400'}`}>
Annuel
</span>
{billingYearly && (
<span className="bg-brand-green/10 text-brand-green text-xs font-bold px-2.5 py-1 rounded-full">
1 mois offert
</span>
)}
</motion.div>
{/* Plans Grid */}
<motion.div <motion.div
variants={containerVariants} variants={containerVariants}
initial="hidden" initial="hidden"
animate={isPricingInView ? 'visible' : 'hidden'} animate={isPricingInView ? 'visible' : 'hidden'}
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 items-stretch" className="grid grid-cols-1 md:grid-cols-3 gap-8"
> >
{pricingPlans.map((plan, index) => ( {pricingPlans.map((plan, index) => (
<motion.div <motion.div
key={plan.key} key={index}
variants={itemVariants} variants={itemVariants}
whileHover={{ y: -6 }} whileHover={{ y: -10 }}
className={`relative flex flex-col rounded-2xl transition-all overflow-hidden ${ className={`relative bg-white rounded-2xl shadow-lg border-2 transition-all ${
plan.highlighted plan.highlighted
? 'bg-brand-navy shadow-2xl ring-2 ring-brand-turquoise' ? 'border-brand-turquoise shadow-2xl scale-105'
: 'bg-white shadow-lg border border-gray-100 hover:shadow-xl hover:border-brand-turquoise/30' : 'border-gray-200 hover:border-brand-turquoise/50'
}`} }`}
> >
{/* Top gradient bar */} {plan.highlighted && (
<div className={`h-1.5 w-full bg-gradient-to-r ${plan.accentColor}`} /> <div className="absolute -top-4 left-1/2 transform -translate-x-1/2">
<span className="bg-brand-turquoise text-white text-sm font-bold px-4 py-1 rounded-full">
{/* Popular badge */} Populaire
{plan.badge && plan.key === 'silver' && (
<div className="absolute top-4 right-4">
<span className="bg-brand-turquoise text-white text-xs font-bold px-2.5 py-1 rounded-full">
{plan.badge}
</span> </span>
</div> </div>
)} )}
{plan.badge && plan.key === 'platinium' && ( <div className="p-8">
<div className="absolute top-4 right-4"> <h3 className="text-2xl font-bold text-brand-navy mb-2">{plan.name}</h3>
<span className="bg-gradient-to-r from-brand-navy to-brand-turquoise text-white text-xs font-bold px-2.5 py-1 rounded-full"> <p className="text-gray-600 mb-6">{plan.description}</p>
{plan.badge}
</span>
</div>
)}
<div className="flex flex-col flex-1 p-6">
{/* Plan name */}
<div className="mb-4">
<div className={`inline-flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wider px-2.5 py-1 rounded-full mb-3 ${plan.highlighted ? 'bg-white/10 text-white/70' : plan.badgeBg}`}>
<div className={`w-1.5 h-1.5 rounded-full bg-gradient-to-r ${plan.accentColor}`} />
{plan.name}
</div>
<h3 className={`text-xl font-bold mb-1 ${plan.highlighted ? 'text-white' : 'text-brand-navy'}`}>
{plan.description}
</h3>
</div>
{/* Price */}
<div className="mb-6"> <div className="mb-6">
{plan.monthlyPrice === null ? ( <span className="text-5xl font-bold text-brand-navy">{plan.price}</span>
<div> <span className="text-gray-500">{plan.period}</span>
<span className={`text-3xl font-bold ${plan.highlighted ? 'text-white' : 'text-brand-navy'}`}>
Sur mesure
</span>
<p className={`text-sm mt-1 ${plan.highlighted ? 'text-white/60' : 'text-gray-500'}`}>
Tarification personnalisée
</p>
</div>
) : plan.monthlyPrice === 0 ? (
<div>
<span className={`text-4xl font-bold ${plan.highlighted ? 'text-white' : 'text-brand-navy'}`}>
Gratuit
</span>
<p className={`text-sm mt-1 ${plan.highlighted ? 'text-white/60' : 'text-gray-500'}`}>
Pour toujours
</p>
</div>
) : (
<div>
<div className="flex items-end gap-1">
<span className={`text-4xl font-bold ${plan.highlighted ? 'text-white' : 'text-brand-navy'}`}>
{billingYearly ? plan.yearlyMonthly : plan.monthlyPrice}
</span>
<span className={`text-sm pb-1.5 ${plan.highlighted ? 'text-white/60' : 'text-gray-500'}`}>
/mois
</span>
</div>
{billingYearly ? (
<p className={`text-xs mt-1 ${plan.highlighted ? 'text-white/60' : 'text-gray-500'}`}>
Facturé {plan.yearlyPrice?.toLocaleString('fr-FR')}/an
</p>
) : (
<p className={`text-xs mt-1 ${plan.highlighted ? 'text-brand-turquoise' : 'text-brand-turquoise'}`}>
Économisez 1 mois avec l'annuel
</p>
)}
</div>
)}
</div> </div>
<ul className="space-y-3 mb-8">
{/* Key stats */}
<div className={`rounded-xl p-3 mb-5 space-y-2 ${plan.highlighted ? 'bg-white/10' : 'bg-gray-50'}`}>
<div className="flex items-center gap-2">
<Users className={`w-3.5 h-3.5 flex-shrink-0 ${plan.highlighted ? 'text-brand-turquoise' : 'text-brand-turquoise'}`} />
<span className={`text-xs font-medium ${plan.highlighted ? 'text-white/80' : 'text-gray-700'}`}>{plan.users}</span>
</div>
<div className="flex items-center gap-2">
<Ship className={`w-3.5 h-3.5 flex-shrink-0 ${plan.highlighted ? 'text-brand-turquoise' : 'text-brand-turquoise'}`} />
<span className={`text-xs font-medium ${plan.highlighted ? 'text-white/80' : 'text-gray-700'}`}>{plan.shipments}</span>
</div>
<div className="flex items-center gap-2">
<BarChart3 className={`w-3.5 h-3.5 flex-shrink-0 ${plan.highlighted ? 'text-brand-turquoise' : 'text-brand-turquoise'}`} />
<span className={`text-xs font-medium ${plan.highlighted ? 'text-white/80' : 'text-gray-700'}`}>
Commission {plan.commission}
</span>
</div>
</div>
{/* Features */}
<ul className="space-y-2.5 mb-6 flex-1">
{plan.features.map((feature, featureIndex) => ( {plan.features.map((feature, featureIndex) => (
<li key={featureIndex} className="flex items-start gap-2.5"> <li key={featureIndex} className="flex items-center">
{feature.included ? ( {feature.included ? (
<Check className={`w-4 h-4 flex-shrink-0 mt-0.5 ${plan.highlighted ? 'text-brand-turquoise' : 'text-brand-green'}`} /> <Check className="w-5 h-5 text-brand-green mr-3 flex-shrink-0" />
) : ( ) : (
<X className={`w-4 h-4 flex-shrink-0 mt-0.5 ${plan.highlighted ? 'text-white/20' : 'text-gray-300'}`} /> <X className="w-5 h-5 text-gray-300 mr-3 flex-shrink-0" />
)} )}
<span className={`text-sm ${ <span className={feature.included ? 'text-gray-700' : 'text-gray-400'}>
feature.included
? plan.highlighted ? 'text-white/90' : 'text-gray-700'
: plan.highlighted ? 'text-white/30' : 'text-gray-400'
}`}>
{feature.text} {feature.text}
</span> </span>
</li> </li>
))} ))}
</ul> </ul>
{/* CTA */}
<Link <Link
href={plan.ctaLink} href={plan.name === 'Enterprise' ? '/contact' : '/register'}
className={`block w-full text-center py-3 px-6 rounded-xl font-semibold text-sm transition-all ${ target={plan.name === 'Enterprise' ? '_self' : '_blank'}
rel={plan.name === 'Enterprise' ? undefined : 'noopener noreferrer'}
className={`block w-full text-center py-3 px-6 rounded-lg font-semibold transition-all ${
plan.highlighted plan.highlighted
? 'bg-brand-turquoise text-white hover:bg-brand-turquoise/90 shadow-lg shadow-brand-turquoise/30 hover:shadow-xl' ? 'bg-brand-turquoise text-white hover:bg-brand-turquoise/90 shadow-lg hover:shadow-xl'
: plan.key === 'bronze' : 'bg-gray-100 text-brand-navy hover:bg-gray-200'
? 'bg-gray-100 text-brand-navy hover:bg-gray-200'
: 'bg-brand-navy text-white hover:bg-brand-navy/90 shadow-md hover:shadow-lg'
}`} }`}
> >
{plan.cta} {plan.cta}
@ -920,21 +734,17 @@ export default function LandingPage() {
))} ))}
</motion.div> </motion.div>
{/* Bottom note */}
<motion.div <motion.div
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={isPricingInView ? { opacity: 1, y: 0 } : {}} animate={isPricingInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.8, delay: 0.5 }} transition={{ duration: 0.8, delay: 0.4 }}
className="mt-12 text-center space-y-2" className="mt-12 text-center"
> >
<p className="text-gray-600 text-sm"> <p className="text-gray-600">
Plans Silver et Gold : essai gratuit 14 jours inclus · Aucune carte bancaire requise Tous les plans incluent un essai gratuit de 14 jours. Aucune carte bancaire requise.
</p> </p>
<p className="text-sm text-gray-500"> <p className="text-sm text-gray-500 mt-2">
Des questions ?{' '} Des questions ? <Link href="/contact" className="text-brand-turquoise hover:underline">Contactez notre équipe commerciale</Link>
<Link href="/contact" className="text-brand-turquoise font-medium hover:underline">
Contactez notre équipe commerciale
</Link>
</p> </p>
</motion.div> </motion.div>
</div> </div>

View File

@ -1,307 +0,0 @@
'use client';
import React, { useState } from 'react';
import Link from 'next/link';
import Image from 'next/image';
import { Check, X, ArrowRight, Shield } from 'lucide-react';
type BillingInterval = 'monthly' | 'yearly';
const PLANS = [
{
name: 'Bronze',
key: 'BRONZE' as const,
monthlyPrice: 0,
yearlyPrice: 0,
description: 'Pour démarrer et tester la plateforme',
maxUsers: 1,
maxShipments: '12/an',
commission: '5%',
support: 'Aucun',
badge: null,
features: [
{ name: 'Recherche de tarifs', included: true },
{ name: 'Réservations', included: true },
{ name: 'Tableau de bord', included: false },
{ name: 'Wiki Maritime', included: false },
{ name: 'Gestion des utilisateurs', included: false },
{ name: 'Import CSV', included: false },
{ name: 'Accès API', included: false },
{ name: 'Interface personnalisée', included: false },
{ name: 'KAM dédié', included: false },
],
cta: 'Commencer gratuitement',
ctaStyle: 'bg-gray-900 text-white hover:bg-gray-800',
popular: false,
},
{
name: 'Silver',
key: 'SILVER' as const,
monthlyPrice: 249,
yearlyPrice: 2739,
description: 'Pour les équipes en croissance',
maxUsers: 5,
maxShipments: 'Illimitées',
commission: '3%',
support: 'Email',
badge: 'silver' as const,
features: [
{ name: 'Recherche de tarifs', included: true },
{ name: 'Réservations', included: true },
{ name: 'Tableau de bord', included: true },
{ name: 'Wiki Maritime', included: true },
{ name: 'Gestion des utilisateurs', included: true },
{ name: 'Import CSV', included: true },
{ name: 'Accès API', included: false },
{ name: 'Interface personnalisée', included: false },
{ name: 'KAM dédié', included: false },
],
cta: 'Choisir Silver',
ctaStyle: 'bg-brand-turquoise text-white hover:opacity-90',
popular: true,
},
{
name: 'Gold',
key: 'GOLD' as const,
monthlyPrice: 899,
yearlyPrice: 9889,
description: 'Pour les entreprises établies',
maxUsers: 20,
maxShipments: 'Illimitées',
commission: '2%',
support: 'Direct',
badge: 'gold' as const,
features: [
{ name: 'Recherche de tarifs', included: true },
{ name: 'Réservations', included: true },
{ name: 'Tableau de bord', included: true },
{ name: 'Wiki Maritime', included: true },
{ name: 'Gestion des utilisateurs', included: true },
{ name: 'Import CSV', included: true },
{ name: 'Accès API', included: true },
{ name: 'Interface personnalisée', included: false },
{ name: 'KAM dédié', included: false },
],
cta: 'Choisir Gold',
ctaStyle: 'bg-yellow-500 text-white hover:bg-yellow-600',
popular: false,
},
{
name: 'Platinium',
key: 'PLATINIUM' as const,
monthlyPrice: -1,
yearlyPrice: -1,
description: 'Solutions sur mesure',
maxUsers: 'Illimité',
maxShipments: 'Illimitées',
commission: '1%',
support: 'KAM dédié',
badge: 'platinium' as const,
features: [
{ name: 'Recherche de tarifs', included: true },
{ name: 'Réservations', included: true },
{ name: 'Tableau de bord', included: true },
{ name: 'Wiki Maritime', included: true },
{ name: 'Gestion des utilisateurs', included: true },
{ name: 'Import CSV', included: true },
{ name: 'Accès API', included: true },
{ name: 'Interface personnalisée', included: true },
{ name: 'KAM dédié', included: true },
],
cta: 'Nous contacter',
ctaStyle: 'bg-purple-600 text-white hover:bg-purple-700',
popular: false,
},
];
function formatPrice(amount: number): string {
return new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: 'EUR',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(amount);
}
export default function PricingPage() {
const [billing, setBilling] = useState<BillingInterval>('monthly');
return (
<div className="min-h-screen bg-white">
{/* Header */}
<header className="border-b">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 flex items-center justify-between">
<Link href="/">
<Image
src="/assets/logos/logo-black.svg"
alt="Xpeditis"
width={40}
height={48}
priority
/>
</Link>
<div className="flex items-center gap-4">
<Link href="/login" className="text-sm text-gray-600 hover:text-gray-900">
Connexion
</Link>
<Link
href="/register"
className="text-sm bg-brand-turquoise text-white px-4 py-2 rounded-lg hover:opacity-90"
>
Inscription
</Link>
</div>
</div>
</header>
{/* Hero */}
<section className="py-16 text-center">
<h1 className="text-4xl font-bold text-gray-900 mb-4">
Des tarifs simples et transparents
</h1>
<p className="text-lg text-gray-600 max-w-2xl mx-auto mb-8">
Choisissez la formule adaptée à votre activité de transport maritime.
Commencez gratuitement, évoluez selon vos besoins.
</p>
{/* Billing toggle */}
<div className="flex items-center justify-center gap-4 mb-12">
<span className={`text-sm font-medium ${billing === 'monthly' ? 'text-gray-900' : 'text-gray-500'}`}>
Mensuel
</span>
<button
onClick={() => setBilling(billing === 'monthly' ? 'yearly' : 'monthly')}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
billing === 'yearly' ? 'bg-brand-turquoise' : 'bg-gray-300'
}`}
>
<span
className={`inline-block h-4 w-4 rounded-full bg-white transition-transform ${
billing === 'yearly' ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
<span className={`text-sm font-medium ${billing === 'yearly' ? 'text-gray-900' : 'text-gray-500'}`}>
Annuel
</span>
{billing === 'yearly' && (
<span className="text-xs bg-green-100 text-green-800 px-2 py-1 rounded-full font-medium">
-1 mois offert
</span>
)}
</div>
</section>
{/* Plans grid */}
<section className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{PLANS.map((plan) => (
<div
key={plan.key}
className={`relative rounded-2xl border-2 p-6 flex flex-col ${
plan.popular
? 'border-brand-turquoise shadow-lg shadow-brand-turquoise/10'
: 'border-gray-200'
}`}
>
{plan.popular && (
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
<span className="bg-brand-turquoise text-white text-xs font-semibold px-3 py-1 rounded-full">
Populaire
</span>
</div>
)}
{/* Plan name & badge */}
<div className="flex items-center gap-2 mb-2">
<h3 className="text-xl font-bold text-gray-900">{plan.name}</h3>
{plan.badge && (
<Shield className={`w-5 h-5 ${
plan.badge === 'silver' ? 'text-slate-500' :
plan.badge === 'gold' ? 'text-yellow-500' :
'text-purple-500'
}`} />
)}
</div>
<p className="text-sm text-gray-500 mb-4">{plan.description}</p>
{/* Price */}
<div className="mb-6">
{plan.monthlyPrice === -1 ? (
<p className="text-3xl font-bold text-gray-900">Sur devis</p>
) : plan.monthlyPrice === 0 ? (
<p className="text-3xl font-bold text-gray-900">Gratuit</p>
) : (
<>
<p className="text-3xl font-bold text-gray-900">
{billing === 'monthly'
? formatPrice(plan.monthlyPrice)
: formatPrice(Math.round(plan.yearlyPrice / 12))}
<span className="text-base font-normal text-gray-500">/mois</span>
</p>
{billing === 'yearly' && (
<p className="text-sm text-gray-500 mt-1">
{formatPrice(plan.yearlyPrice)}/an (11 mois)
</p>
)}
</>
)}
</div>
{/* Quick stats */}
<div className="space-y-2 mb-6 text-sm">
<div className="flex justify-between">
<span className="text-gray-500">Utilisateurs</span>
<span className="font-medium">{plan.maxUsers}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">Expéditions</span>
<span className="font-medium">{plan.maxShipments}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">Commission</span>
<span className="font-medium">{plan.commission}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">Support</span>
<span className="font-medium">{plan.support}</span>
</div>
</div>
{/* Features */}
<div className="flex-1 space-y-2 mb-6">
{plan.features.map((feature) => (
<div key={feature.name} className="flex items-center gap-2 text-sm">
{feature.included ? (
<Check className="w-4 h-4 text-green-500 flex-shrink-0" />
) : (
<X className="w-4 h-4 text-gray-300 flex-shrink-0" />
)}
<span className={feature.included ? 'text-gray-700' : 'text-gray-400'}>
{feature.name}
</span>
</div>
))}
</div>
{/* CTA */}
<Link
href={plan.key === 'PLATINIUM' ? '/contact' : '/register'}
className={`block text-center py-3 px-4 rounded-lg text-sm font-semibold transition-all ${plan.ctaStyle}`}
>
{plan.cta}
<ArrowRight className="inline-block w-4 h-4 ml-1" />
</Link>
</div>
))}
</div>
</section>
{/* Footer */}
<footer className="border-t py-8 text-center text-sm text-gray-500">
<p>Tous les prix sont en euros HT. Facturation annuelle = 11 mois.</p>
</footer>
</div>
);
}

View File

@ -27,7 +27,6 @@ export default function RegisterPage() {
// Organization fields // Organization fields
const [organizationName, setOrganizationName] = useState(''); const [organizationName, setOrganizationName] = useState('');
const [organizationType, setOrganizationType] = useState<OrganizationType>('FREIGHT_FORWARDER'); const [organizationType, setOrganizationType] = useState<OrganizationType>('FREIGHT_FORWARDER');
const [siren, setSiren] = useState('');
const [street, setStreet] = useState(''); const [street, setStreet] = useState('');
const [city, setCity] = useState(''); const [city, setCity] = useState('');
const [state, setState] = useState(''); const [state, setState] = useState('');
@ -88,11 +87,6 @@ export default function RegisterPage() {
return; 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()) { if (!street.trim() || !city.trim() || !postalCode.trim() || !country.trim()) {
setError('Tous les champs d\'adresse sont requis'); setError('Tous les champs d\'adresse sont requis');
return; return;
@ -114,7 +108,6 @@ export default function RegisterPage() {
organization: { organization: {
name: organizationName, name: organizationName,
type: organizationType, type: organizationType,
siren,
street, street,
city, city,
state: state || undefined, state: state || undefined,
@ -316,25 +309,6 @@ export default function RegisterPage() {
</select> </select>
</div> </div>
{/* SIREN */}
<div className="mb-4">
<label htmlFor="siren" className="label">
Numero SIREN *
</label>
<input
id="siren"
type="text"
required
value={siren}
onChange={e => setSiren(e.target.value.replace(/\D/g, ''))}
className="input w-full"
placeholder="123456789"
maxLength={9}
disabled={isLoading}
/>
<p className="mt-1.5 text-body-xs text-neutral-500">9 chiffres, obligatoire pour toute organisation</p>
</div>
{/* Street Address */} {/* Street Address */}
<div className="mb-4"> <div className="mb-4">
<label htmlFor="street" className="label"> <label htmlFor="street" className="label">

View File

@ -23,7 +23,6 @@ const prefixPublicPaths = [
'/press', '/press',
'/contact', '/contact',
'/carrier', '/carrier',
'/pricing',
]; ];
export function middleware(request: NextRequest) { export function middleware(request: NextRequest) {

View File

@ -7,8 +7,7 @@
'use client'; 'use client';
import { useState, useRef, useEffect } from 'react'; import { useState, useRef, useEffect } from 'react';
import { Download, FileSpreadsheet, FileText, ChevronDown, Lock } from 'lucide-react'; import { Download, FileSpreadsheet, FileText, ChevronDown } from 'lucide-react';
import { useSubscription } from '@/lib/context/subscription-context';
interface ExportButtonProps<T> { interface ExportButtonProps<T> {
data: T[]; data: T[];
@ -27,8 +26,6 @@ export default function ExportButton<T extends Record<string, any>>({
columns, columns,
disabled = false, disabled = false,
}: ExportButtonProps<T>) { }: ExportButtonProps<T>) {
const { hasFeature } = useSubscription();
const canExport = hasFeature('csv_export');
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [isExporting, setIsExporting] = useState(false); const [isExporting, setIsExporting] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
@ -174,12 +171,9 @@ export default function ExportButton<T extends Record<string, any>>({
return ( return (
<div className="relative" ref={dropdownRef}> <div className="relative" ref={dropdownRef}>
<button <button
onClick={() => canExport ? setIsOpen(!isOpen) : window.location.href = '/pricing'} onClick={() => setIsOpen(!isOpen)}
disabled={disabled || data.length === 0 || isExporting} disabled={disabled || data.length === 0 || isExporting}
title={!canExport ? 'Passez au plan Silver pour exporter vos données' : undefined} className="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
className={`inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md bg-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors ${
canExport ? 'text-gray-700 hover:bg-gray-50' : 'text-gray-400 hover:bg-gray-50'
}`}
> >
{isExporting ? ( {isExporting ? (
<> <>
@ -206,19 +200,15 @@ export default function ExportButton<T extends Record<string, any>>({
</> </>
) : ( ) : (
<> <>
{canExport ? ( <Download className="mr-2 h-4 w-4" />
<Download className="mr-2 h-4 w-4" />
) : (
<Lock className="mr-2 h-4 w-4" />
)}
Exporter Exporter
{canExport && <ChevronDown className="ml-2 h-4 w-4" />} <ChevronDown className="ml-2 h-4 w-4" />
</> </>
)} )}
</button> </button>
{/* Dropdown Menu */} {/* Dropdown Menu */}
{isOpen && !isExporting && canExport && ( {isOpen && !isExporting && (
<div className="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-gray-200 z-50"> <div className="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-gray-200 z-50">
<div className="py-1"> <div className="py-1">
<button <button

View File

@ -80,11 +80,21 @@ export function LandingFooter() {
Contact Contact
</Link> </Link>
</li> </li>
<li>
<Link href="/careers" className="hover:text-brand-turquoise transition-colors">
Carrières
</Link>
</li>
<li> <li>
<Link href="/blog" className="hover:text-brand-turquoise transition-colors"> <Link href="/blog" className="hover:text-brand-turquoise transition-colors">
Blog Blog
</Link> </Link>
</li> </li>
<li>
<Link href="/press" className="hover:text-brand-turquoise transition-colors">
Presse
</Link>
</li>
</ul> </ul>
</div> </div>
@ -107,6 +117,11 @@ export function LandingFooter() {
Politique de cookies Politique de cookies
</Link> </Link>
</li> </li>
<li>
<Link href="/security" className="hover:text-brand-turquoise transition-colors">
Sécurité
</Link>
</li>
<li> <li>
<Link href="/compliance" className="hover:text-brand-turquoise transition-colors"> <Link href="/compliance" className="hover:text-brand-turquoise transition-colors">
Conformité RGPD Conformité RGPD

View File

@ -6,6 +6,8 @@ import Image from 'next/image';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { import {
ChevronDown, ChevronDown,
Briefcase,
Newspaper,
Info, Info,
BookOpen, BookOpen,
LayoutDashboard, LayoutDashboard,
@ -24,12 +26,14 @@ export function LandingHeader({ transparentOnTop = false, activePage }: LandingH
const companyMenuItems = [ const companyMenuItems = [
{ href: '/about', label: 'À propos', icon: Info, description: 'Notre histoire et mission' }, { 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: '/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) // "Entreprise" dropdown is active only for its own sub-pages (not contact)
const isCompanyMenuActive = const isCompanyMenuActive =
activePage !== undefined && ['about', 'blog'].includes(activePage); activePage !== undefined && ['about', 'careers', 'blog', 'press'].includes(activePage);
const getUserInitials = () => { const getUserInitials = () => {
if (!user) return ''; if (!user) return '';

View File

@ -65,6 +65,7 @@ export default function LicensesTab() {
<p className="text-2xl font-bold text-gray-900"> <p className="text-2xl font-bold text-gray-900">
{subscription?.usedLicenses || 0} {subscription?.usedLicenses || 0}
</p> </p>
<p className="text-xs text-gray-400 mt-1">Hors ADMIN (illimité)</p>
</div> </div>
<div className="bg-white rounded-lg p-4 border border-gray-200"> <div className="bg-white rounded-lg p-4 border border-gray-200">
<p className="text-sm text-gray-500">Licences disponibles</p> <p className="text-sm text-gray-500">Licences disponibles</p>

View File

@ -117,7 +117,7 @@ export default function SubscriptionTab() {
}); });
const handleUpgrade = (plan: SubscriptionPlan) => { const handleUpgrade = (plan: SubscriptionPlan) => {
if (plan === 'BRONZE') return; if (plan === 'FREE') return;
setSelectedPlan(plan); setSelectedPlan(plan);
checkoutMutation.mutate(plan); checkoutMutation.mutate(plan);
}; };
@ -149,7 +149,7 @@ export default function SubscriptionTab() {
const canUpgrade = (plan: SubscriptionPlan): boolean => { const canUpgrade = (plan: SubscriptionPlan): boolean => {
if (!subscription) return false; if (!subscription) return false;
const planOrder: SubscriptionPlan[] = ['BRONZE', 'SILVER', 'GOLD', 'PLATINIUM']; const planOrder: SubscriptionPlan[] = ['FREE', 'STARTER', 'PRO', 'ENTERPRISE'];
return planOrder.indexOf(plan) > planOrder.indexOf(subscription.plan); return planOrder.indexOf(plan) > planOrder.indexOf(subscription.plan);
}; };
@ -230,7 +230,7 @@ export default function SubscriptionTab() {
</span> </span>
)} )}
</div> </div>
{subscription.plan !== 'BRONZE' && ( {subscription.plan !== 'FREE' && (
<button <button
onClick={handleManageBilling} onClick={handleManageBilling}
disabled={portalMutation.isPending} disabled={portalMutation.isPending}
@ -314,7 +314,7 @@ export default function SubscriptionTab() {
}`} }`}
> >
Annuel Annuel
<span className="ml-1 text-xs text-green-600">-1 mois</span> <span className="ml-1 text-xs text-green-600">-20%</span>
</button> </button>
</div> </div>
</div> </div>
@ -333,7 +333,7 @@ export default function SubscriptionTab() {
<h4 className="text-lg font-semibold text-gray-900">{plan.name}</h4> <h4 className="text-lg font-semibold text-gray-900">{plan.name}</h4>
<div className="mt-3"> <div className="mt-3">
<span className="text-2xl font-bold text-gray-900"> <span className="text-2xl font-bold text-gray-900">
{plan.plan === 'PLATINIUM' {plan.plan === 'ENTERPRISE'
? 'Sur devis' ? 'Sur devis'
: formatPrice( : formatPrice(
billingInterval === 'yearly' billingInterval === 'yearly'
@ -341,7 +341,7 @@ export default function SubscriptionTab() {
: plan.monthlyPriceEur, : plan.monthlyPriceEur,
)} )}
</span> </span>
{plan.plan !== 'PLATINIUM' && plan.plan !== 'BRONZE' && ( {plan.plan !== 'ENTERPRISE' && plan.plan !== 'FREE' && (
<span className="text-gray-500 text-sm"> <span className="text-gray-500 text-sm">
/{billingInterval === 'yearly' ? 'an' : 'mois'} /{billingInterval === 'yearly' ? 'an' : 'mois'}
</span> </span>
@ -381,7 +381,7 @@ export default function SubscriptionTab() {
> >
Plan actuel Plan actuel
</button> </button>
) : plan.plan === 'PLATINIUM' ? ( ) : plan.plan === 'ENTERPRISE' ? (
<a <a
href="mailto:sales@xpeditis.com?subject=Demande Enterprise" href="mailto:sales@xpeditis.com?subject=Demande Enterprise"
className="block w-full px-4 py-2 text-sm font-medium text-center text-blue-600 bg-blue-50 rounded-lg hover:bg-blue-100 transition" className="block w-full px-4 py-2 text-sm font-medium text-center text-blue-600 bg-blue-50 rounded-lg hover:bg-blue-100 transition"

View File

@ -9,7 +9,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { AuthProvider } from '@/lib/context/auth-context'; import { AuthProvider } from '@/lib/context/auth-context';
import { SubscriptionProvider } from '@/lib/context/subscription-context';
import { CookieProvider } from '@/lib/context/cookie-context'; import { CookieProvider } from '@/lib/context/cookie-context';
import CookieConsent from '@/components/CookieConsent'; import CookieConsent from '@/components/CookieConsent';
@ -31,12 +30,10 @@ export function Providers({ children }: { children: React.ReactNode }) {
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<AuthProvider> <AuthProvider>
<SubscriptionProvider> <CookieProvider>
<CookieProvider> {children}
{children} <CookieConsent />
<CookieConsent /> </CookieProvider>
</CookieProvider>
</SubscriptionProvider>
</AuthProvider> </AuthProvider>
</QueryClientProvider> </QueryClientProvider>
); );

View File

@ -1,68 +0,0 @@
'use client';
import React from 'react';
import Link from 'next/link';
import { Lock } from 'lucide-react';
import { useSubscription } from '@/lib/context/subscription-context';
import type { PlanFeature } from '@/lib/api/subscriptions';
interface FeatureGateProps {
feature: PlanFeature;
children: React.ReactNode;
fallback?: React.ReactNode;
}
const FEATURE_MIN_PLAN: Record<PlanFeature, string> = {
dashboard: 'Silver',
wiki: 'Silver',
user_management: 'Silver',
csv_export: 'Silver',
api_access: 'Gold',
custom_interface: 'Platinium',
dedicated_kam: 'Platinium',
};
export default function FeatureGate({ feature, children, fallback }: FeatureGateProps) {
const { hasFeature, loading } = useSubscription();
if (loading) {
return <>{children}</>;
}
if (hasFeature(feature)) {
return <>{children}</>;
}
if (fallback) {
return <>{fallback}</>;
}
const minPlan = FEATURE_MIN_PLAN[feature] || 'Silver';
return (
<div className="relative">
<div className="opacity-30 pointer-events-none select-none blur-sm">
{children}
</div>
<div className="absolute inset-0 flex items-center justify-center bg-white/60 backdrop-blur-[2px] rounded-lg">
<div className="text-center p-8 max-w-md">
<div className="mx-auto w-12 h-12 bg-gray-100 rounded-full flex items-center justify-center mb-4">
<Lock className="w-6 h-6 text-gray-400" />
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Fonctionnalité {minPlan}+
</h3>
<p className="text-sm text-gray-600 mb-4">
Cette fonctionnalité nécessite le plan {minPlan} ou supérieur.
</p>
<Link
href="/pricing"
className="inline-flex items-center px-4 py-2 bg-brand-turquoise text-white text-sm font-medium rounded-lg hover:opacity-90 transition-opacity"
>
Voir les plans
</Link>
</div>
</div>
</div>
);
}

View File

@ -1,49 +0,0 @@
'use client';
import React from 'react';
import { Shield } from 'lucide-react';
interface StatusBadgeProps {
badge: 'none' | 'silver' | 'gold' | 'platinium';
size?: 'sm' | 'md';
}
const BADGE_CONFIG = {
none: null,
silver: {
label: 'Silver',
bg: 'bg-slate-100',
text: 'text-slate-700',
icon: 'text-slate-500',
},
gold: {
label: 'Gold',
bg: 'bg-yellow-100',
text: 'text-yellow-800',
icon: 'text-yellow-600',
},
platinium: {
label: 'Platinium',
bg: 'bg-purple-100',
text: 'text-purple-800',
icon: 'text-purple-600',
},
};
export default function StatusBadge({ badge, size = 'sm' }: StatusBadgeProps) {
const config = BADGE_CONFIG[badge];
if (!config) return null;
const sizeClasses = size === 'sm'
? 'text-xs px-2 py-0.5 gap-1'
: 'text-sm px-3 py-1 gap-1.5';
const iconSize = size === 'sm' ? 'w-3 h-3' : 'w-4 h-4';
return (
<span className={`inline-flex items-center font-medium rounded-full ${config.bg} ${config.text} ${sizeClasses}`}>
<Shield className={`${iconSize} ${config.icon}`} />
{config.label}
</span>
);
}

View File

@ -80,39 +80,6 @@ export async function getAdminOrganization(id: string): Promise<OrganizationResp
return get<OrganizationResponse>(`/api/v1/admin/organizations/${id}`); return get<OrganizationResponse>(`/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 ==================== // ==================== BOOKINGS ====================
/** /**
@ -134,16 +101,6 @@ export async function getAdminBooking(id: string): Promise<BookingResponse> {
return get<BookingResponse>(`/api/v1/admin/bookings/${id}`); return get<BookingResponse>(`/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<BookingResponse> {
return post<BookingResponse>(`/api/v1/admin/bookings/${bookingId}/validate-transfer`, {});
}
// ==================== DOCUMENTS ==================== // ==================== DOCUMENTS ====================
/** /**

View File

@ -51,7 +51,7 @@ export interface CsvBookingResponse {
primaryCurrency: string; primaryCurrency: string;
transitDays: number; transitDays: number;
containerType: string; containerType: string;
status: 'PENDING_PAYMENT' | 'PENDING' | 'ACCEPTED' | 'REJECTED' | 'CANCELLED'; status: 'PENDING' | 'ACCEPTED' | 'REJECTED';
documents: Array<{ documents: Array<{
type: string; type: string;
fileName: string; fileName: string;
@ -64,14 +64,6 @@ export interface CsvBookingResponse {
rejectedAt?: string; rejectedAt?: string;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
commissionRate?: number;
commissionAmountEur?: number;
}
export interface CommissionPaymentResponse {
sessionUrl: string;
sessionId: string;
commissionAmountEur: number;
} }
export interface CsvBookingListResponse { export interface CsvBookingListResponse {
@ -295,34 +287,3 @@ export async function rejectCsvBooking(
false // includeAuth = false false // includeAuth = false
); );
} }
/**
* Create Stripe Checkout session for commission payment
* POST /api/v1/csv-bookings/:id/pay
*/
export async function payBookingCommission(
bookingId: string
): Promise<CommissionPaymentResponse> {
return post<CommissionPaymentResponse>(`/api/v1/csv-bookings/${bookingId}/pay`, {});
}
/**
* Confirm commission payment after Stripe redirect
* POST /api/v1/csv-bookings/:id/confirm-payment
*/
export async function confirmBookingPayment(
bookingId: string,
sessionId: string
): Promise<CsvBookingResponse> {
return post<CsvBookingResponse>(`/api/v1/csv-bookings/${bookingId}/confirm-payment`, {
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<CsvBookingResponse> {
return post<CsvBookingResponse>(`/api/v1/csv-bookings/${bookingId}/declare-transfer`, {});
}

View File

@ -9,16 +9,7 @@ import { get, post } from './client';
/** /**
* Subscription plan types * Subscription plan types
*/ */
export type SubscriptionPlan = 'BRONZE' | 'SILVER' | 'GOLD' | 'PLATINIUM'; export type SubscriptionPlan = 'FREE' | 'STARTER' | 'PRO' | 'ENTERPRISE';
export type PlanFeature =
| 'dashboard'
| 'wiki'
| 'user_management'
| 'csv_export'
| 'api_access'
| 'custom_interface'
| 'dedicated_kam';
/** /**
* Subscription status types * Subscription status types
@ -47,11 +38,6 @@ export interface PlanDetails {
maxLicenses: number; maxLicenses: number;
monthlyPriceEur: number; monthlyPriceEur: number;
yearlyPriceEur: number; yearlyPriceEur: number;
maxShipmentsPerYear: number;
commissionRatePercent: number;
supportLevel: 'none' | 'email' | 'direct' | 'dedicated_kam';
statusBadge: 'none' | 'silver' | 'gold' | 'platinium';
planFeatures: PlanFeature[];
features: string[]; features: string[];
} }
@ -204,14 +190,14 @@ export function formatPrice(amount: number, currency = 'EUR'): string {
*/ */
export function getPlanBadgeColor(plan: SubscriptionPlan): string { export function getPlanBadgeColor(plan: SubscriptionPlan): string {
switch (plan) { switch (plan) {
case 'BRONZE': case 'FREE':
return 'bg-orange-100 text-orange-800'; return 'bg-gray-100 text-gray-800';
case 'SILVER': case 'STARTER':
return 'bg-slate-100 text-slate-800'; return 'bg-blue-100 text-blue-800';
case 'GOLD': case 'PRO':
return 'bg-yellow-100 text-yellow-800';
case 'PLATINIUM':
return 'bg-purple-100 text-purple-800'; return 'bg-purple-100 text-purple-800';
case 'ENTERPRISE':
return 'bg-amber-100 text-amber-800';
default: default:
return 'bg-gray-100 text-gray-800'; return 'bg-gray-100 text-gray-800';
} }

View File

@ -1,77 +0,0 @@
'use client';
import React, { createContext, useContext, useState, useEffect } from 'react';
import { useAuth } from './auth-context';
import {
getSubscriptionOverview,
type SubscriptionOverviewResponse,
type SubscriptionPlan,
type PlanFeature,
} from '../api/subscriptions';
interface SubscriptionContextType {
subscription: SubscriptionOverviewResponse | null;
loading: boolean;
plan: SubscriptionPlan | null;
planFeatures: PlanFeature[];
hasFeature: (feature: PlanFeature) => boolean;
refresh: () => Promise<void>;
}
const SubscriptionContext = createContext<SubscriptionContextType | undefined>(undefined);
export function SubscriptionProvider({ children }: { children: React.ReactNode }) {
const { user, isAuthenticated } = useAuth();
const [subscription, setSubscription] = useState<SubscriptionOverviewResponse | null>(null);
const [loading, setLoading] = useState(true);
const fetchSubscription = async () => {
if (!isAuthenticated) {
setSubscription(null);
setLoading(false);
return;
}
try {
const data = await getSubscriptionOverview();
setSubscription(data);
} catch (error) {
console.error('Failed to fetch subscription:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchSubscription();
}, [isAuthenticated, user?.organizationId]);
const plan = subscription?.plan ?? null;
const planFeatures = subscription?.planDetails?.planFeatures ?? [];
const hasFeature = (feature: PlanFeature): boolean => {
return planFeatures.includes(feature);
};
return (
<SubscriptionContext.Provider
value={{
subscription,
loading,
plan,
planFeatures,
hasFeature,
refresh: fetchSubscription,
}}
>
{children}
</SubscriptionContext.Provider>
);
}
export function useSubscription() {
const context = useContext(SubscriptionContext);
if (context === undefined) {
throw new Error('useSubscription must be used within a SubscriptionProvider');
}
return context;
}

View File

@ -11,7 +11,6 @@
export interface RegisterOrganizationData { export interface RegisterOrganizationData {
name: string; name: string;
type: OrganizationType; type: OrganizationType;
siren: string;
street: string; street: string;
city: string; city: string;
state?: string; state?: string;
@ -121,7 +120,6 @@ export interface CreateOrganizationRequest {
export interface UpdateOrganizationRequest { export interface UpdateOrganizationRequest {
name?: string; name?: string;
siren?: string; siren?: string;
siret?: string;
eori?: string; eori?: string;
contact_phone?: string; contact_phone?: string;
contact_email?: string; contact_email?: string;
@ -151,9 +149,6 @@ export interface OrganizationResponse {
scac?: string | null; scac?: string | null;
siren?: string | null; siren?: string | null;
eori?: string | null; eori?: string | null;
siret?: string | null;
siretVerified?: boolean;
statusBadge?: 'none' | 'silver' | 'gold' | 'platinium';
contact_phone?: string | null; contact_phone?: string | null;
contact_email?: string | null; contact_email?: string | null;
address: OrganizationAddress; address: OrganizationAddress;