payment methode

This commit is contained in:
David 2026-03-18 15:11:09 +01:00
parent 1c6edb9d41
commit 230d06dc98
76 changed files with 3087 additions and 826 deletions

View File

@ -84,6 +84,8 @@ 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)
@ -186,7 +188,14 @@ Immutable, self-validating via static `create()`. E.g. `Money` supports USD, EUR
- Separate mapper classes (`infrastructure/persistence/typeorm/mappers/`) with static `toOrm()`, `toDomain()`, `toDomainMany()` methods - 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. Per-module files (auth.ts, bookings.ts, rates.ts, etc.) import from client. Custom Fetch wrapper in `src/lib/api/client.ts` — exports `get()`, `post()`, `patch()`, `del()`, `upload()`, `download()`. Auto-refreshes JWT on 401. Tokens stored in localStorage **and synced to cookies** (`accessToken` cookie) so Next.js middleware can read them server-side. Per-module files (auth.ts, bookings.ts, rates.ts, etc.) import from client.
### Route Protection (Middleware)
`apps/frontend/middleware.ts` checks the `accessToken` cookie to protect routes. Public paths are defined in two lists:
- `exactPublicPaths`: exact matches (e.g. `/`)
- `prefixPublicPaths`: prefix matches including sub-paths (e.g. `/login`, `/carrier`, `/about`, etc.)
All other routes redirect to `/login?redirect=<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_STARTER_MONTHLY_PRICE_ID=price_starter_monthly STRIPE_SILVER_MONTHLY_PRICE_ID=price_silver_monthly
STRIPE_STARTER_YEARLY_PRICE_ID=price_starter_yearly STRIPE_SILVER_YEARLY_PRICE_ID=price_silver_yearly
STRIPE_PRO_MONTHLY_PRICE_ID=price_pro_monthly STRIPE_GOLD_MONTHLY_PRICE_ID=price_gold_monthly
STRIPE_PRO_YEARLY_PRICE_ID=price_pro_yearly STRIPE_GOLD_YEARLY_PRICE_ID=price_gold_yearly
STRIPE_ENTERPRISE_MONTHLY_PRICE_ID=price_enterprise_monthly STRIPE_PLATINIUM_MONTHLY_PRICE_ID=price_platinium_monthly
STRIPE_ENTERPRISE_YEARLY_PRICE_ID=price_enterprise_yearly STRIPE_PLATINIUM_YEARLY_PRICE_ID=price_platinium_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_STARTER_MONTHLY_PRICE_ID: Joi.string().optional(), STRIPE_SILVER_MONTHLY_PRICE_ID: Joi.string().optional(),
STRIPE_STARTER_YEARLY_PRICE_ID: Joi.string().optional(), STRIPE_SILVER_YEARLY_PRICE_ID: Joi.string().optional(),
STRIPE_PRO_MONTHLY_PRICE_ID: Joi.string().optional(), STRIPE_GOLD_MONTHLY_PRICE_ID: Joi.string().optional(),
STRIPE_PRO_YEARLY_PRICE_ID: Joi.string().optional(), STRIPE_GOLD_YEARLY_PRICE_ID: Joi.string().optional(),
STRIPE_ENTERPRISE_MONTHLY_PRICE_ID: Joi.string().optional(), STRIPE_PLATINIUM_MONTHLY_PRICE_ID: Joi.string().optional(),
STRIPE_ENTERPRISE_YEARLY_PRICE_ID: Joi.string().optional(), STRIPE_PLATINIUM_YEARLY_PRICE_ID: Joi.string().optional(),
}), }),
}), }),

View File

@ -25,6 +25,8 @@ 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';
} }
@ -39,7 +41,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
) {} ) {}
/** /**
@ -220,11 +222,26 @@ 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 }> {
// Fetch subscription plan for JWT payload
let plan = 'BRONZE';
let planFeatures: string[] = [];
try {
const subscription = await this.subscriptionService.getOrCreateSubscription(
user.organizationId
);
plan = subscription.plan.value;
planFeatures = [...subscription.plan.planFeatures];
} catch (error) {
this.logger.warn(`Failed to fetch subscription for JWT: ${error}`);
}
const accessPayload: JwtPayload = { 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',
}; };
@ -233,6 +250,8 @@ 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',
}; };

View File

@ -6,15 +6,18 @@ 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';
@ -29,6 +32,7 @@ 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
@ -47,6 +51,7 @@ import { WebhooksModule } from '../webhooks/webhooks.module';
ContainerOrmEntity, ContainerOrmEntity,
RateQuoteOrmEntity, RateQuoteOrmEntity,
UserOrmEntity, UserOrmEntity,
CsvBookingOrmEntity,
]), ]),
EmailModule, EmailModule,
PdfModule, PdfModule,
@ -54,6 +59,7 @@ import { WebhooksModule } from '../webhooks/webhooks.module';
AuditModule, AuditModule,
NotificationsModule, NotificationsModule,
WebhooksModule, WebhooksModule,
SubscriptionsModule,
], ],
controllers: [BookingsController], controllers: [BookingsController],
providers: [ providers: [
@ -73,6 +79,10 @@ import { WebhooksModule } from '../webhooks/webhooks.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

@ -53,6 +53,12 @@ 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')
@ -70,7 +76,9 @@ 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()
@ -105,6 +113,22 @@ 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 = {
@ -456,9 +480,16 @@ 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.booking !== null && item.booking !== undefined && item
item.rateQuote !== null && item.rateQuote !== undefined ): item is {
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

@ -14,7 +14,9 @@ import {
BadRequestException, BadRequestException,
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,
@ -29,6 +31,12 @@ 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 { ShipmentLimitExceededException } from '@domain/exceptions/shipment-limit-exceeded.exception';
import { import {
CreateCsvBookingDto, CreateCsvBookingDto,
CsvBookingResponseDto, CsvBookingResponseDto,
@ -48,7 +56,13 @@ import {
@ApiTags('CSV Bookings') @ApiTags('CSV Bookings')
@Controller('csv-bookings') @Controller('csv-bookings')
export class CsvBookingsController { export class CsvBookingsController {
constructor(private readonly csvBookingService: CsvBookingService) {} constructor(
private readonly csvBookingService: CsvBookingService,
private readonly subscriptionService: SubscriptionService,
private readonly configService: ConfigService,
@Inject(SHIPMENT_COUNTER_PORT)
private readonly shipmentCounter: ShipmentCounterPort
) {}
// ============================================================================ // ============================================================================
// STATIC ROUTES (must come FIRST) // STATIC ROUTES (must come FIRST)
@ -60,7 +74,6 @@ 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')
@ -144,6 +157,20 @@ export class CsvBookingsController {
const userId = req.user.id; const userId = req.user.id;
const organizationId = req.user.organizationId; const organizationId = req.user.organizationId;
// Check shipment limit (Bronze plan = 12/year)
const subscription = await this.subscriptionService.getOrCreateSubscription(organizationId);
const maxShipments = subscription.plan.maxShipmentsPerYear;
if (maxShipments !== -1) {
const currentYear = new Date().getFullYear();
const count = await this.shipmentCounter.countShipmentsForOrganizationInYear(
organizationId,
currentYear
);
if (count >= maxShipments) {
throw new ShipmentLimitExceededException(organizationId, count, maxShipments);
}
}
// Convert string values to numbers (multipart/form-data sends everything as strings) // Convert string values to numbers (multipart/form-data sends everything as strings)
const sanitizedDto: CreateCsvBookingDto = { const sanitizedDto: CreateCsvBookingDto = {
...dto, ...dto,
@ -341,6 +368,85 @@ export class CsvBookingsController {
}; };
} }
/**
* Create Stripe Checkout session for commission payment
*
* POST /api/v1/csv-bookings/:id/pay
*/
@Post(':id/pay')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({
summary: 'Pay commission for a booking',
description:
'Creates a Stripe Checkout session for the commission payment. Returns the Stripe session URL to redirect the user to.',
})
@ApiParam({ name: 'id', description: 'Booking ID (UUID)' })
@ApiResponse({
status: 200,
description: 'Stripe checkout session created',
schema: {
type: 'object',
properties: {
sessionUrl: { type: 'string' },
sessionId: { type: 'string' },
commissionAmountEur: { type: 'number' },
},
},
})
@ApiResponse({ status: 400, description: 'Booking not in PENDING_PAYMENT status' })
@ApiResponse({ status: 404, description: 'Booking not found' })
async payCommission(@Param('id') id: string, @Request() req: any) {
const userId = req.user.id;
const userEmail = req.user.email;
const frontendUrl = this.configService.get<string>('FRONTEND_URL') || 'http://localhost:3000';
return await this.csvBookingService.createCommissionPayment(id, userId, userEmail, frontendUrl);
}
/**
* Confirm commission payment after Stripe redirect
*
* POST /api/v1/csv-bookings/:id/confirm-payment
*/
@Post(':id/confirm-payment')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({
summary: 'Confirm commission payment',
description:
'Called after Stripe payment success. Verifies the payment, updates booking to PENDING, sends email to carrier.',
})
@ApiParam({ name: 'id', description: 'Booking ID (UUID)' })
@ApiBody({
schema: {
type: 'object',
required: ['sessionId'],
properties: {
sessionId: { type: 'string', description: 'Stripe Checkout session ID' },
},
},
})
@ApiResponse({
status: 200,
description: 'Payment confirmed, booking activated',
type: CsvBookingResponseDto,
})
@ApiResponse({ status: 400, description: 'Payment not completed or session mismatch' })
@ApiResponse({ status: 404, description: 'Booking not found' })
async confirmPayment(
@Param('id') id: string,
@Body('sessionId') sessionId: string,
@Request() req: any
): Promise<CsvBookingResponseDto> {
if (!sessionId) {
throw new BadRequestException('sessionId is required');
}
const userId = req.user.id;
return await this.csvBookingService.confirmCommissionPayment(id, sessionId, userId);
}
// ============================================================================ // ============================================================================
// PARAMETERIZED ROUTES (must come LAST) // PARAMETERIZED ROUTES (must come LAST)
// ============================================================================ // ============================================================================

View File

@ -22,12 +22,7 @@ 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 { import { UpdateConsentDto, ConsentResponseDto, WithdrawConsentDto } from '../dto/consent.dto';
UpdateConsentDto,
ConsentResponseDto,
WithdrawConsentDto,
ConsentSuccessDto,
} from '../dto/consent.dto';
@ApiTags('GDPR') @ApiTags('GDPR')
@Controller('gdpr') @Controller('gdpr')

View File

@ -77,7 +77,7 @@ 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); return this.subscriptionService.getSubscriptionOverview(user.organizationId);
@ -139,8 +139,7 @@ export class SubscriptionsController {
@ApiBearerAuth() @ApiBearerAuth()
@ApiOperation({ @ApiOperation({
summary: 'Create checkout session', summary: 'Create checkout session',
description: description: 'Create a Stripe Checkout session for upgrading subscription. Admin/Manager only.',
'Create a Stripe Checkout session for upgrading subscription. Admin/Manager only.',
}) })
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
@ -157,14 +156,10 @@ 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( return this.subscriptionService.createCheckoutSession(user.organizationId, user.id, dto);
user.organizationId,
user.id,
dto,
);
} }
/** /**
@ -195,7 +190,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);
@ -230,10 +225,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);
} }
@ -247,7 +242,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,8 +44,10 @@ 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';
@ -64,14 +66,15 @@ import { SubscriptionService } from '../services/subscription.service';
*/ */
@ApiTags('Users') @ApiTags('Users')
@Controller('users') @Controller('users')
@UseGuards(JwtAuthGuard, RolesGuard) @UseGuards(JwtAuthGuard, RolesGuard, FeatureFlagGuard)
@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
) {} ) {}
/** /**
@ -284,7 +287,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,13 +1,18 @@
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 { 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
@ -17,12 +22,22 @@ import { StorageModule } from '../infrastructure/storage/storage.module';
@Module({ @Module({
imports: [ imports: [
TypeOrmModule.forFeature([CsvBookingOrmEntity]), TypeOrmModule.forFeature([CsvBookingOrmEntity]),
ConfigModule,
NotificationsModule, NotificationsModule,
EmailModule, EmailModule,
StorageModule, StorageModule,
SubscriptionsModule,
StripeModule,
], ],
controllers: [CsvBookingsController, CsvBookingActionsController], controllers: [CsvBookingsController, CsvBookingActionsController],
providers: [CsvBookingService, TypeOrmCsvBookingRepository], providers: [
CsvBookingService,
TypeOrmCsvBookingRepository,
{
provide: SHIPMENT_COUNTER_PORT,
useClass: TypeOrmShipmentCounterRepository,
},
],
exports: [CsvBookingService, TypeOrmCsvBookingRepository], exports: [CsvBookingService, TypeOrmCsvBookingRepository],
}) })
export class CsvBookingsModule {} export class CsvBookingsModule {}

View File

@ -7,9 +7,12 @@
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) @UseGuards(JwtAuthGuard, FeatureFlagGuard)
@RequiresFeature('dashboard')
export class DashboardController { export class DashboardController {
constructor(private readonly analyticsService: AnalyticsService) {} constructor(private readonly analyticsService: AnalyticsService) {}

View File

@ -8,11 +8,13 @@ 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], imports: [BookingsModule, RatesModule, CsvBookingsModule, SubscriptionsModule],
controllers: [DashboardController], controllers: [DashboardController],
providers: [AnalyticsService], providers: [AnalyticsService, FeatureFlagGuard],
exports: [AnalyticsService], exports: [AnalyticsService],
}) })
export class DashboardModule {} export class DashboardModule {}

View File

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

View File

@ -1,112 +1,118 @@
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: ['BILL_OF_LADING', 'PACKING_LIST', 'COMMERCIAL_INVOICE', 'CERTIFICATE_OF_ORIGIN', 'OTHER'], enum: [
}) 'BILL_OF_LADING',
type: string; 'PACKING_LIST',
'COMMERCIAL_INVOICE',
@ApiProperty({ description: 'Original file name' }) 'CERTIFICATE_OF_ORIGIN',
fileName: string; 'OTHER',
],
@ApiProperty({ description: 'File MIME type' }) })
mimeType: string; type: string;
@ApiProperty({ description: 'File size in bytes' }) @ApiProperty({ description: 'Original file name' })
size: number; fileName: string;
@ApiProperty({ description: 'Temporary signed download URL (valid for 1 hour)' }) @ApiProperty({ description: 'File MIME type' })
downloadUrl: string; mimeType: string;
}
@ApiProperty({ description: 'File size in bytes' })
/** size: number;
* Carrier Documents Response DTO
* @ApiProperty({ description: 'Temporary signed download URL (valid for 1 hour)' })
* Response for carrier document access page downloadUrl: string;
*/ }
export class CarrierDocumentsResponseDto {
@ApiProperty({ description: 'Booking summary information', type: BookingSummaryDto }) /**
booking: BookingSummaryDto; * Carrier Documents Response DTO
*
@ApiProperty({ description: 'List of documents with download URLs', type: [DocumentWithUrlDto] }) * Response for carrier document access page
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, IsDateString, IsIP } from 'class-validator'; import { IsBoolean, IsOptional, IsString, IsEnum } 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', 'ACCEPTED', 'REJECTED', 'CANCELLED'], enum: ['PENDING_PAYMENT', 'PENDING', 'ACCEPTED', 'REJECTED', 'CANCELLED'],
example: 'PENDING', example: 'PENDING_PAYMENT',
}) })
status: string; status: string;
@ -353,6 +353,18 @@ 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;
} }
/** /**
@ -414,6 +426,12 @@ 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

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

View File

@ -0,0 +1,103 @@
import {
Injectable,
CanActivate,
ExecutionContext,
ForbiddenException,
Inject,
Logger,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { PlanFeature } from '@domain/value-objects/plan-feature.vo';
import {
SubscriptionRepository,
SUBSCRIPTION_REPOSITORY,
} from '@domain/ports/out/subscription.repository';
import { REQUIRED_FEATURES_KEY } from '../decorators/requires-feature.decorator';
/**
* Feature Flag Guard
*
* Checks if the user's subscription plan includes the required features.
* First tries to read plan from JWT payload (fast path), falls back to DB lookup.
*
* Usage:
* @UseGuards(JwtAuthGuard, RolesGuard, FeatureFlagGuard)
* @RequiresFeature('dashboard')
*/
@Injectable()
export class FeatureFlagGuard implements CanActivate {
private readonly logger = new Logger(FeatureFlagGuard.name);
constructor(
private readonly reflector: Reflector,
@Inject(SUBSCRIPTION_REPOSITORY)
private readonly subscriptionRepository: SubscriptionRepository
) {}
async canActivate(context: ExecutionContext): Promise<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;
}
// 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

@ -17,6 +17,7 @@ import {
} 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 { 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,
@ -30,6 +31,7 @@ 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)
@ -62,7 +64,10 @@ 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
) {} ) {}
/** /**
@ -114,7 +119,18 @@ 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);
// Create domain entity // Calculate commission based on organization's subscription plan
let commissionRate = 5; // default Bronze
let commissionAmountEur = 0;
try {
const subscription = await this.subscriptionService.getOrCreateSubscription(organizationId);
commissionRate = subscription.plan.commissionRatePercent;
} catch (error: any) {
this.logger.error(`Failed to get subscription for commission: ${error?.message}`);
}
commissionAmountEur = Math.round(dto.priceEUR * commissionRate) / 100;
// Create domain entity in PENDING_PAYMENT status (no email sent yet)
const booking = new CsvBooking( const booking = new CsvBooking(
bookingId, bookingId,
userId, userId,
@ -131,12 +147,16 @@ export class CsvBookingService {
dto.primaryCurrency, dto.primaryCurrency,
dto.transitDays, dto.transitDays,
dto.containerType, dto.containerType,
CsvBookingStatus.PENDING, CsvBookingStatus.PENDING_PAYMENT,
documents, documents,
confirmationToken, confirmationToken,
new Date(), new Date(),
undefined, undefined,
dto.notes dto.notes,
undefined,
bookingNumber,
commissionRate,
commissionAmountEur
); );
// Save to database // Save to database
@ -152,58 +172,173 @@ export class CsvBookingService {
await this.csvBookingRepository['repository'].save(ormBooking); await this.csvBookingRepository['repository'].save(ormBooking);
} }
this.logger.log(`CSV booking created with ID: ${bookingId}, number: ${bookingNumber}`); this.logger.log(
`CSV booking created with ID: ${bookingId}, number: ${bookingNumber}, status: PENDING_PAYMENT, commission: ${commissionRate}% = ${commissionAmountEur}`
);
// Send email to carrier and WAIT for confirmation // NO email sent to carrier yet - will be sent after commission payment
// The button waits for the email to be sent before responding // NO notification yet - will be created after payment confirmation
return this.toResponseDto(savedBooking);
}
/**
* Create a Stripe Checkout session for commission payment
*/
async createCommissionPayment(
bookingId: string,
userId: string,
userEmail: string,
frontendUrl: string
): Promise<{ sessionUrl: string; sessionId: string; commissionAmountEur: number }> {
const booking = await this.csvBookingRepository.findById(bookingId);
if (!booking) {
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
}
if (booking.userId !== userId) {
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
}
if (booking.status !== CsvBookingStatus.PENDING_PAYMENT) {
throw new BadRequestException(
`Booking is not awaiting payment. Current status: ${booking.status}`
);
}
const commissionAmountEur = booking.commissionAmountEur || 0;
if (commissionAmountEur <= 0) {
throw new BadRequestException('Commission amount is invalid');
}
const amountCents = Math.round(commissionAmountEur * 100);
const result = await this.stripeAdapter.createCommissionCheckout({
bookingId: booking.id,
amountCents,
currency: 'eur',
customerEmail: userEmail,
organizationId: booking.organizationId,
bookingDescription: `Commission booking ${booking.bookingNumber || booking.id} - ${booking.origin.getValue()}${booking.destination.getValue()}`,
successUrl: `${frontendUrl}/dashboard/booking/${booking.id}/payment-success?session_id={CHECKOUT_SESSION_ID}`,
cancelUrl: `${frontendUrl}/dashboard/booking/${booking.id}/pay`,
});
this.logger.log(
`Created Stripe commission checkout for booking ${bookingId}: ${amountCents} cents EUR`
);
return {
sessionUrl: result.sessionUrl,
sessionId: result.sessionId,
commissionAmountEur,
};
}
/**
* Confirm commission payment and activate booking
* Called after Stripe redirect with session_id
*/
async confirmCommissionPayment(
bookingId: string,
sessionId: string,
userId: string
): Promise<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(dto.carrierEmail, { await this.emailAdapter.sendCsvBookingRequest(booking.carrierEmail, {
bookingId, bookingId: booking.id,
bookingNumber, bookingNumber: bookingNumber || '',
documentPassword, documentPassword: documentPassword || '',
origin: dto.origin, origin: booking.origin.getValue(),
destination: dto.destination, destination: booking.destination.getValue(),
volumeCBM: dto.volumeCBM, volumeCBM: booking.volumeCBM,
weightKG: dto.weightKG, weightKG: booking.weightKG,
palletCount: dto.palletCount, palletCount: booking.palletCount,
priceUSD: dto.priceUSD, priceUSD: booking.priceUSD,
priceEUR: dto.priceEUR, priceEUR: booking.priceEUR,
primaryCurrency: dto.primaryCurrency, primaryCurrency: booking.primaryCurrency,
transitDays: dto.transitDays, transitDays: booking.transitDays,
containerType: dto.containerType, containerType: booking.containerType,
documents: documents.map(doc => ({ documents: booking.documents.map(doc => ({
type: doc.type, type: doc.type,
fileName: doc.fileName, fileName: doc.fileName,
})), })),
confirmationToken, confirmationToken: booking.confirmationToken,
notes: dto.notes, notes: booking.notes,
}); });
this.logger.log(`Email sent to carrier: ${dto.carrierEmail}`); this.logger.log(`Email sent to carrier: ${booking.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, userId: booking.userId,
organizationId, organizationId: booking.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 ${dto.carrierName} for ${dto.origin}${dto.destination} has been sent successfully.`, message: `Your booking request to ${booking.carrierName} for ${booking.getRouteDescription()} has been sent successfully after payment.`,
metadata: { bookingId, carrierName: dto.carrierName }, metadata: { bookingId: booking.id, carrierName: booking.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(savedBooking); return this.toResponseDto(updatedBooking);
} }
/** /**
@ -394,6 +529,21 @@ 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`);
@ -568,6 +718,7 @@ 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,
@ -583,6 +734,7 @@ 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,
@ -678,9 +830,15 @@ 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 or ACCEPTED bookings // Allow adding documents to PENDING_PAYMENT, PENDING, or ACCEPTED bookings
if (booking.status !== CsvBookingStatus.PENDING && booking.status !== CsvBookingStatus.ACCEPTED) { if (
throw new BadRequestException('Cannot add documents to a booking that is rejected or cancelled'); booking.status !== CsvBookingStatus.PENDING_PAYMENT &&
booking.status !== CsvBookingStatus.PENDING &&
booking.status !== CsvBookingStatus.ACCEPTED
) {
throw new BadRequestException(
'Cannot add documents to a booking that is rejected or cancelled'
);
} }
// Upload new documents // Upload new documents
@ -723,7 +881,10 @@ 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(`Failed to send new documents notification: ${error?.message}`, error?.stack); this.logger.error(
`Failed to send new documents notification: ${error?.message}`,
error?.stack
);
} }
} }
@ -755,8 +916,11 @@ export class CsvBookingService {
throw new NotFoundException(`Booking with ID ${bookingId} not found`); throw new NotFoundException(`Booking with ID ${bookingId} not found`);
} }
// Verify booking is still pending // Verify booking is still pending or awaiting payment
if (booking.status !== CsvBookingStatus.PENDING) { if (
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');
} }
@ -871,7 +1035,9 @@ export class CsvBookingService {
await this.csvBookingRepository['repository'].save(ormBooking); await this.csvBookingRepository['repository'].save(ormBooking);
} }
this.logger.log(`Replaced document ${documentId} with ${newDocument.id} in booking ${bookingId}`); this.logger.log(
`Replaced document ${documentId} with ${newDocument.id} in booking ${bookingId}`
);
return { return {
success: true, success: true,
@ -947,6 +1113,8 @@ 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,10 +120,7 @@ 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( async recordConsent(userId: string, consentData: UpdateConsentDto): Promise<ConsentResponseDto> {
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
) {} ) {}
/** /**
@ -72,11 +72,11 @@ export class InvitationService {
const canInviteResult = await this.subscriptionService.canInviteUser(organizationId); 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,24 +4,14 @@
* Business logic for subscription and license management. * Business logic for subscription and license management.
*/ */
import { import { Injectable, Inject, Logger, NotFoundException, BadRequestException } from '@nestjs/common';
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 { import { LicenseRepository, LICENSE_REPOSITORY } from '@domain/ports/out/license.repository';
LicenseRepository,
LICENSE_REPOSITORY,
} from '@domain/ports/out/license.repository';
import { import {
OrganizationRepository, OrganizationRepository,
ORGANIZATION_REPOSITORY, ORGANIZATION_REPOSITORY,
@ -30,14 +20,10 @@ import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.reposito
import { StripePort, STRIPE_PORT } from '@domain/ports/out/stripe.port'; import { 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 { import { SubscriptionPlan, SubscriptionPlanType } from '@domain/value-objects/subscription-plan.vo';
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 {
@ -69,32 +55,28 @@ 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
*/ */
async getSubscriptionOverview( async getSubscriptionOverview(organizationId: string): Promise<SubscriptionOverviewResponseDto> {
organizationId: string,
): Promise<SubscriptionOverviewResponseDto> {
const subscription = await this.getOrCreateSubscription(organizationId); const subscription = await this.getOrCreateSubscription(organizationId);
const activeLicenses = await this.licenseRepository.findActiveBySubscriptionId( const activeLicenses = await this.licenseRepository.findActiveBySubscriptionId(subscription.id);
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; const maxLicenses = subscription.maxLicenses;
const availableLicenses = subscription.isUnlimited() const availableLicenses = subscription.isUnlimited()
@ -123,9 +105,7 @@ export class SubscriptionService {
* Get all available plans * Get all available plans
*/ */
getAllPlans(): AllPlansResponseDto { getAllPlans(): AllPlansResponseDto {
const plans = SubscriptionPlan.getAllPlans().map((plan) => const plans = SubscriptionPlan.getAllPlans().map(plan => this.mapPlanToDto(plan));
this.mapPlanToDto(plan),
);
return { plans }; return { plans };
} }
@ -137,13 +117,12 @@ export class SubscriptionService {
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.isActive() && (subscription.isUnlimited() || usedLicenses < maxLicenses);
(subscription.isUnlimited() || usedLicenses < maxLicenses);
const availableLicenses = subscription.isUnlimited() const availableLicenses = subscription.isUnlimited()
? -1 ? -1
@ -171,7 +150,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) {
@ -184,23 +163,19 @@ export class SubscriptionService {
} }
// Cannot checkout for FREE plan // Cannot checkout for FREE plan
if (dto.plan === SubscriptionPlanDto.FREE) { if (dto.plan === SubscriptionPlanDto.BRONZE) {
throw new BadRequestException('Cannot create checkout session for FREE plan'); throw new BadRequestException('Cannot create checkout session for Bronze plan');
} }
const subscription = await this.getOrCreateSubscription(organizationId); const subscription = await this.getOrCreateSubscription(organizationId);
const frontendUrl = this.configService.get<string>( const frontendUrl = this.configService.get<string>('FRONTEND_URL', 'http://localhost:3000');
'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 || dto.cancelUrl || `${frontendUrl}/dashboard/settings/organization?canceled=true`;
`${frontendUrl}/dashboard/settings/organization?canceled=true`;
const result = await this.stripeAdapter.createCheckoutSession({ const result = await this.stripeAdapter.createCheckoutSession({
organizationId, organizationId,
@ -214,7 +189,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 {
@ -228,24 +203,18 @@ 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( const subscription = await this.subscriptionRepository.findByOrganizationId(organizationId);
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>( const frontendUrl = this.configService.get<string>('FRONTEND_URL', 'http://localhost:3000');
'FRONTEND_URL', const returnUrl = dto.returnUrl || `${frontendUrl}/dashboard/settings/organization`;
'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,
@ -267,11 +236,9 @@ 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( let subscription = await this.subscriptionRepository.findByOrganizationId(organizationId);
organizationId,
);
if (!subscription) { if (!subscription) {
subscription = await this.getOrCreateSubscription(organizationId); subscription = await this.getOrCreateSubscription(organizationId);
@ -283,12 +250,14 @@ export class SubscriptionService {
// If we have a session ID, ALWAYS retrieve the checkout session to get the latest subscription details // 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(`Retrieving checkout session ${sessionId} for organization ${organizationId}`); this.logger.log(
`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
@ -330,7 +299,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);
@ -354,13 +323,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);
@ -418,14 +387,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
); );
} }
} }
@ -474,22 +443,18 @@ 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( let subscription = await this.subscriptionRepository.findByOrganizationId(organizationId);
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.free(), plan: SubscriptionPlan.bronze(),
}); });
subscription = await this.subscriptionRepository.save(subscription); subscription = await this.subscriptionRepository.save(subscription);
this.logger.log( this.logger.log(`Created Bronze subscription for organization ${organizationId}`);
`Created FREE subscription for organization ${organizationId}`,
);
} }
return subscription; return subscription;
@ -497,9 +462,7 @@ export class SubscriptionService {
// Private helper methods // Private helper methods
private async handleCheckoutCompleted( private async handleCheckoutCompleted(session: Record<string, unknown>): Promise<void> {
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;
@ -537,27 +500,26 @@ 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);
this.logger.log( // Update organization status badge to match the plan
`Updated subscription for organization ${organizationId} to plan ${plan}`, await this.updateOrganizationBadge(organizationId, subscription.statusBadge);
);
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( let subscription = 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`);
@ -576,7 +538,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);
@ -584,9 +546,7 @@ 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( this.logger.warn(`Cannot update to plan ${plan} - would exceed license limit`);
`Cannot update to plan ${plan} - would exceed license limit`,
);
} }
} }
@ -597,22 +557,26 @@ 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 = await this.subscriptionRepository.findByStripeSubscriptionId( const subscription =
subscriptionId, 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`);
@ -622,42 +586,41 @@ 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.free(), SubscriptionPlan.bronze(),
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);
this.logger.log(`Subscription ${subscriptionId} canceled, downgraded to FREE`); // Reset organization badge to 'none' on cancellation
if (subscription.organizationId) {
await this.updateOrganizationBadge(subscription.organizationId, 'none');
}
this.logger.log(`Subscription ${subscriptionId} canceled, downgraded to Bronze`);
} }
private async handlePaymentFailed(invoice: Record<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( const subscription = await this.subscriptionRepository.findByStripeCustomerId(customerId);
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( const updatedSubscription = subscription.updateStatus(SubscriptionStatus.pastDue());
SubscriptionStatus.pastDue(),
);
await this.subscriptionRepository.save(updatedSubscription); await this.subscriptionRepository.save(updatedSubscription);
this.logger.log( this.logger.log(`Subscription ${subscription.id} marked as past due due to payment failure`);
`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,
@ -671,6 +634,19 @@ 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,
@ -678,6 +654,11 @@ 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,14 +7,13 @@ 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: [ imports: [TypeOrmModule.forFeature([UserOrmEntity]), SubscriptionsModule],
TypeOrmModule.forFeature([UserOrmEntity]),
SubscriptionsModule,
],
controllers: [UsersController], controllers: [UsersController],
providers: [ providers: [
FeatureFlagGuard,
{ {
provide: USER_REPOSITORY, provide: USER_REPOSITORY,
useClass: TypeOrmUserRepository, useClass: TypeOrmUserRepository,

View File

@ -50,6 +50,8 @@ 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;
} }
@ -161,6 +163,14 @@ 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;
} }
@ -270,6 +280,19 @@ export class Booking {
}); });
} }
/**
* Apply commission to the booking
*/
applyCommission(ratePercent: number, baseAmountEur: number): Booking {
const commissionAmount = Math.round(baseAmountEur * ratePercent) / 100;
return new Booking({
...this.props,
commissionRate: ratePercent,
commissionAmountEur: commissionAmount,
updatedAt: new Date(),
});
}
/** /**
* Check if booking can be cancelled * Check if booking can be cancelled
*/ */

View File

@ -6,6 +6,7 @@ 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 = '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
@ -80,7 +81,10 @@ 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();
} }
@ -144,6 +148,29 @@ export class CsvBooking {
} }
} }
/**
* Apply commission to the booking
*/
applyCommission(ratePercent: number, baseAmountEur: number): void {
this.commissionRate = ratePercent;
this.commissionAmountEur = Math.round(baseAmountEur * ratePercent) / 100;
}
/**
* Mark commission payment as completed transition to PENDING
*
* @throws Error if booking is not in PENDING_PAYMENT status
*/
markPaymentCompleted(): void {
if (this.status !== CsvBookingStatus.PENDING_PAYMENT) {
throw new Error(
`Cannot mark payment completed for booking with status ${this.status}. Only PENDING_PAYMENT bookings can transition.`
);
}
this.status = CsvBookingStatus.PENDING;
}
/** /**
* Accept the booking * Accept the booking
* *
@ -202,6 +229,10 @@ 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();
} }
@ -211,6 +242,10 @@ 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;
@ -363,7 +398,10 @@ 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);
@ -392,6 +430,9 @@ 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,10 +5,7 @@
* Each active user in an organization consumes one license. * Each active user in an organization consumes one license.
*/ */
import { import { LicenseStatus, LicenseStatusType } from '../value-objects/license-status.vo';
LicenseStatus,
LicenseStatusType,
} from '../value-objects/license-status.vo';
export interface LicenseProps { export interface LicenseProps {
readonly id: string; readonly id: string;
@ -29,11 +26,7 @@ export class License {
/** /**
* Create a new license for a user * Create a new license for a user
*/ */
static create(props: { static create(props: { id: string; subscriptionId: string; userId: string }): License {
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,6 +44,9 @@ 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;
@ -59,9 +62,19 @@ export class Organization {
/** /**
* Factory method to create a new Organization * Factory method to create a new Organization
*/ */
static create(props: Omit<OrganizationProps, 'createdAt' | 'updatedAt'>): Organization { static create(
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.');
@ -79,6 +92,8 @@ 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,
}); });
@ -100,6 +115,10 @@ 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;
@ -153,6 +172,18 @@ 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;
} }
@ -183,6 +214,25 @@ 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,10 +5,7 @@
* Stripe integration, and billing period information. * Stripe integration, and billing period information.
*/ */
import { import { SubscriptionPlan, SubscriptionPlanType } from '../value-objects/subscription-plan.vo';
SubscriptionPlan,
SubscriptionPlanType,
} from '../value-objects/subscription-plan.vo';
import { import {
SubscriptionStatus, SubscriptionStatus,
SubscriptionStatusType, SubscriptionStatusType,
@ -40,7 +37,7 @@ export class Subscription {
} }
/** /**
* Create a new subscription (defaults to FREE plan) * Create a new subscription (defaults to Bronze/free plan)
*/ */
static create(props: { static create(props: {
id: string; id: string;
@ -53,7 +50,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.free(), plan: props.plan ?? SubscriptionPlan.bronze(),
status: SubscriptionStatus.active(), status: SubscriptionStatus.active(),
stripeCustomerId: props.stripeCustomerId ?? null, stripeCustomerId: props.stripeCustomerId ?? null,
stripeSubscriptionId: props.stripeSubscriptionId ?? null, stripeSubscriptionId: props.stripeSubscriptionId ?? null,
@ -68,10 +65,41 @@ 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: SubscriptionPlanType; plan: string; // Accepts both old and new plan names
status: SubscriptionStatusType; status: SubscriptionStatusType;
stripeCustomerId: string | null; stripeCustomerId: string | null;
stripeSubscriptionId: string | null; stripeSubscriptionId: string | null;
@ -84,7 +112,7 @@ export class Subscription {
return new Subscription({ return new Subscription({
id: props.id, id: props.id,
organizationId: props.organizationId, organizationId: props.organizationId,
plan: SubscriptionPlan.create(props.plan), plan: SubscriptionPlan.fromString(props.plan),
status: SubscriptionStatus.create(props.status), status: SubscriptionStatus.create(props.status),
stripeCustomerId: props.stripeCustomerId, stripeCustomerId: props.stripeCustomerId,
stripeSubscriptionId: props.stripeSubscriptionId, stripeSubscriptionId: props.stripeSubscriptionId,
@ -236,7 +264,7 @@ export class Subscription {
this.props.plan.value, this.props.plan.value,
newPlan.value, newPlan.value,
currentUserCount, currentUserCount,
newPlan.maxLicenses, newPlan.maxLicenses
); );
} }

View File

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

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,11 +60,9 @@ 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( super(`Subscription ${subscriptionId} is not active. Current status: ${currentStatus}`);
`Subscription ${subscriptionId} is not active. Current status: ${currentStatus}`,
);
this.name = 'SubscriptionNotActiveException'; this.name = 'SubscriptionNotActiveException';
Object.setPrototypeOf(this, SubscriptionNotActiveException.prototype); Object.setPrototypeOf(this, SubscriptionNotActiveException.prototype);
} }
@ -73,13 +71,10 @@ 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( Object.setPrototypeOf(this, InvalidSubscriptionStatusTransitionException.prototype);
this,
InvalidSubscriptionStatusTransitionException.prototype,
);
} }
} }

View File

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

View File

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

View File

@ -43,6 +43,22 @@ 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;
@ -62,16 +78,19 @@ export interface StripePort {
/** /**
* Create a Stripe Checkout session for subscription purchase * Create a Stripe Checkout session for subscription purchase
*/ */
createCheckoutSession( createCheckoutSession(input: CreateCheckoutSessionInput): Promise<CreateCheckoutSessionOutput>;
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( createPortalSession(input: CreatePortalSessionInput): Promise<CreatePortalSessionOutput>;
input: CreatePortalSessionInput,
): Promise<CreatePortalSessionOutput>;
/** /**
* Retrieve subscription details from Stripe * Retrieve subscription details from Stripe
@ -101,10 +120,7 @@ export interface StripePort {
/** /**
* Verify and parse a Stripe webhook event * Verify and parse a Stripe webhook event
*/ */
constructWebhookEvent( constructWebhookEvent(payload: string | Buffer, signature: string): Promise<StripeWebhookEvent>;
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

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

View File

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

View File

@ -0,0 +1,50 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
SiretVerificationPort,
SiretVerificationResult,
} from '@domain/ports/out/siret-verification.port';
@Injectable()
export class PappersSiretAdapter implements SiretVerificationPort {
private readonly logger = new Logger(PappersSiretAdapter.name);
private readonly apiKey: string;
private readonly baseUrl = 'https://api.pappers.fr/v2';
constructor(private readonly configService: ConfigService) {
this.apiKey = this.configService.get<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,6 +92,18 @@ 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', 'ACCEPTED', 'REJECTED', 'CANCELLED'], enum: ['PENDING_PAYMENT', 'PENDING', 'ACCEPTED', 'REJECTED', 'CANCELLED'],
default: 'PENDING', default: 'PENDING_PAYMENT',
}) })
@Index() @Index()
status: 'PENDING' | 'ACCEPTED' | 'REJECTED' | 'CANCELLED'; status: 'PENDING_PAYMENT' | 'PENDING' | 'ACCEPTED' | 'REJECTED' | 'CANCELLED';
@Column({ name: 'documents', type: 'jsonb' }) @Column({ name: 'documents', type: 'jsonb' })
documents: Array<{ documents: Array<{
@ -141,6 +141,21 @@ 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,14 +5,7 @@
* Represents user licenses linked to subscriptions. * Represents user licenses linked to subscriptions.
*/ */
import { import { Entity, Column, PrimaryGeneratedColumn, ManyToOne, JoinColumn, Index } from 'typeorm';
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';
@ -30,7 +23,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,6 +56,15 @@ 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 = 'FREE' | 'STARTER' | 'PRO' | 'ENTERPRISE'; export type SubscriptionPlanOrmType = 'BRONZE' | 'SILVER' | 'GOLD' | 'PLATINIUM';
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: ['FREE', 'STARTER', 'PRO', 'ENTERPRISE'], enum: ['BRONZE', 'SILVER', 'GOLD', 'PLATINIUM'],
default: 'FREE', default: 'BRONZE',
}) })
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,6 +27,8 @@ 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;
@ -52,6 +54,9 @@ 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,7 +42,10 @@ 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
); );
} }
@ -66,13 +69,16 @@ 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 'PENDING' | 'ACCEPTED' | 'REJECTED' | 'CANCELLED', status: domain.status as CsvBookingOrmEntity['status'],
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,
}; };
} }
@ -81,10 +87,13 @@ export class CsvBookingMapper {
*/ */
static toOrmUpdate(domain: CsvBooking): Partial<CsvBookingOrmEntity> { static toOrmUpdate(domain: CsvBooking): Partial<CsvBookingOrmEntity> {
return { return {
status: domain.status as 'PENDING' | 'ACCEPTED' | 'REJECTED' | 'CANCELLED', status: domain.status as CsvBookingOrmEntity['status'],
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,6 +30,9 @@ 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;
@ -59,6 +62,9 @@ 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

@ -0,0 +1,92 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Rename subscription plans:
* FREE -> BRONZE, STARTER -> SILVER, PRO -> GOLD, ENTERPRISE -> PLATINIUM
*
* PostgreSQL does not support removing values from an enum type directly,
* so we create a new enum, migrate the column, and drop the old one.
*/
export class RenamePlansToBronzeSilverGoldPlatinium1740000000001 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<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

@ -0,0 +1,43 @@
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

@ -0,0 +1,23 @@
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

@ -0,0 +1,75 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Migration: Add PENDING_PAYMENT status to csv_bookings enum + stripe_payment_intent_id column
*/
export class AddPendingPaymentStatus1740000000004 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<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

@ -0,0 +1,32 @@
/**
* Shipment Counter Repository
*
* Counts total shipments (bookings + CSV bookings) for an organization in a year.
* Used to enforce Bronze plan's 12 shipments/year limit.
*/
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ShipmentCounterPort } from '@domain/ports/out/shipment-counter.port';
import { CsvBookingOrmEntity } from '../entities/csv-booking.orm-entity';
@Injectable()
export class TypeOrmShipmentCounterRepository implements ShipmentCounterPort {
constructor(
@InjectRepository(CsvBookingOrmEntity)
private readonly csvBookingRepository: Repository<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,9 +35,7 @@ export class TypeOrmSubscriptionRepository implements SubscriptionRepository {
return orm ? SubscriptionOrmMapper.toDomain(orm) : null; return orm ? SubscriptionOrmMapper.toDomain(orm) : null;
} }
async findByStripeSubscriptionId( async findByStripeSubscriptionId(stripeSubscriptionId: string): Promise<Subscription | null> {
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,6 +11,8 @@ import {
StripePort, StripePort,
CreateCheckoutSessionInput, CreateCheckoutSessionInput,
CreateCheckoutSessionOutput, CreateCheckoutSessionOutput,
CreateCommissionCheckoutInput,
CreateCommissionCheckoutOutput,
CreatePortalSessionInput, CreatePortalSessionInput,
CreatePortalSessionOutput, CreatePortalSessionOutput,
StripeSubscriptionData, StripeSubscriptionData,
@ -42,50 +44,46 @@ 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 starterMonthly = this.configService.get<string>('STRIPE_STARTER_MONTHLY_PRICE_ID'); const silverMonthly = this.configService.get<string>('STRIPE_SILVER_MONTHLY_PRICE_ID');
const starterYearly = this.configService.get<string>('STRIPE_STARTER_YEARLY_PRICE_ID'); const silverYearly = this.configService.get<string>('STRIPE_SILVER_YEARLY_PRICE_ID');
const proMonthly = this.configService.get<string>('STRIPE_PRO_MONTHLY_PRICE_ID'); const goldMonthly = this.configService.get<string>('STRIPE_GOLD_MONTHLY_PRICE_ID');
const proYearly = this.configService.get<string>('STRIPE_PRO_YEARLY_PRICE_ID'); const goldYearly = this.configService.get<string>('STRIPE_GOLD_YEARLY_PRICE_ID');
const enterpriseMonthly = this.configService.get<string>('STRIPE_ENTERPRISE_MONTHLY_PRICE_ID'); const platiniumMonthly = this.configService.get<string>('STRIPE_PLATINIUM_MONTHLY_PRICE_ID');
const enterpriseYearly = this.configService.get<string>('STRIPE_ENTERPRISE_YEARLY_PRICE_ID'); const platiniumYearly = this.configService.get<string>('STRIPE_PLATINIUM_YEARLY_PRICE_ID');
if (starterMonthly) this.priceIdMap.set(starterMonthly, 'STARTER'); if (silverMonthly) this.priceIdMap.set(silverMonthly, 'SILVER');
if (starterYearly) this.priceIdMap.set(starterYearly, 'STARTER'); if (silverYearly) this.priceIdMap.set(silverYearly, 'SILVER');
if (proMonthly) this.priceIdMap.set(proMonthly, 'PRO'); if (goldMonthly) this.priceIdMap.set(goldMonthly, 'GOLD');
if (proYearly) this.priceIdMap.set(proYearly, 'PRO'); if (goldYearly) this.priceIdMap.set(goldYearly, 'GOLD');
if (enterpriseMonthly) this.priceIdMap.set(enterpriseMonthly, 'ENTERPRISE'); if (platiniumMonthly) this.priceIdMap.set(platiniumMonthly, 'PLATINIUM');
if (enterpriseYearly) this.priceIdMap.set(enterpriseYearly, 'ENTERPRISE'); if (platiniumYearly) this.priceIdMap.set(platiniumYearly, 'PLATINIUM');
this.planPriceMap.set('STARTER', { this.planPriceMap.set('SILVER', {
monthly: starterMonthly || '', monthly: silverMonthly || '',
yearly: starterYearly || '', yearly: silverYearly || '',
}); });
this.planPriceMap.set('PRO', { this.planPriceMap.set('GOLD', {
monthly: proMonthly || '', monthly: goldMonthly || '',
yearly: proYearly || '', yearly: goldYearly || '',
}); });
this.planPriceMap.set('ENTERPRISE', { this.planPriceMap.set('PLATINIUM', {
monthly: enterpriseMonthly || '', monthly: platiniumMonthly || '',
yearly: enterpriseYearly || '', yearly: platiniumYearly || '',
}); });
} }
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' const priceId = input.billingInterval === 'yearly' ? planPrices.yearly : planPrices.monthly;
? planPrices.yearly
: planPrices.monthly;
if (!priceId) { if (!priceId) {
throw new Error( throw new Error(`No ${input.billingInterval} price configured for plan: ${input.plan}`);
`No ${input.billingInterval} price configured for plan: ${input.plan}`,
);
} }
const sessionParams: Stripe.Checkout.SessionCreateParams = { const sessionParams: Stripe.Checkout.SessionCreateParams = {
@ -119,7 +117,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 {
@ -128,9 +126,46 @@ export class StripeAdapter implements StripePort {
}; };
} }
async createPortalSession( async createCommissionCheckout(
input: CreatePortalSessionInput, input: CreateCommissionCheckoutInput
): Promise<CreatePortalSessionOutput> { ): Promise<CreateCommissionCheckoutOutput> {
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,
@ -211,13 +246,9 @@ 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( const event = this.stripe.webhooks.constructEvent(payload, signature, this.webhookSecret);
payload,
signature,
this.webhookSecret,
);
return { return {
type: event.type, type: event.type,

View File

@ -0,0 +1,286 @@
/**
* Commission Payment Page
*
* Shows booking summary and commission amount, allows payment via Stripe or bank transfer
*/
'use client';
import { useState, useEffect } from 'react';
import { useRouter, useParams } from 'next/navigation';
import { CreditCard, Building2, ArrowLeft, Loader2, AlertTriangle, CheckCircle } from 'lucide-react';
import { getCsvBooking, payBookingCommission } from '@/lib/api/bookings';
interface BookingData {
id: string;
bookingNumber?: string;
carrierName: string;
carrierEmail: string;
origin: string;
destination: string;
volumeCBM: number;
weightKG: number;
palletCount: number;
priceEUR: number;
priceUSD: number;
primaryCurrency: string;
transitDays: number;
containerType: string;
status: string;
commissionRate?: number;
commissionAmountEur?: number;
}
export default function PayCommissionPage() {
const router = useRouter();
const params = useParams();
const bookingId = params.id as string;
const [booking, setBooking] = useState<BookingData | null>(null);
const [loading, setLoading] = useState(true);
const [paying, setPaying] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function fetchBooking() {
try {
const data = await getCsvBooking(bookingId);
setBooking(data as any);
// If booking is not in PENDING_PAYMENT status, redirect
if (data.status !== 'PENDING_PAYMENT') {
router.replace('/dashboard/bookings');
}
} catch (err) {
console.error('Failed to fetch booking:', err);
setError('Impossible de charger les details du booking');
} finally {
setLoading(false);
}
}
if (bookingId) {
fetchBooking();
}
}, [bookingId, router]);
const handlePayByCard = async () => {
setPaying(true);
setError(null);
try {
const result = await payBookingCommission(bookingId);
// Redirect to Stripe Checkout
window.location.href = result.sessionUrl;
} catch (err) {
console.error('Payment error:', err);
setError(err instanceof Error ? err.message : 'Erreur lors de la creation du paiement');
setPaying(false);
}
};
const formatPrice = (price: number, currency: string) => {
return new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency,
}).format(price);
};
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100">
<div className="flex items-center space-x-3">
<Loader2 className="h-6 w-6 animate-spin text-blue-600" />
<span className="text-gray-700">Chargement...</span>
</div>
</div>
);
}
if (error && !booking) {
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">
<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;
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 py-12 px-4">
<div className="max-w-2xl mx-auto">
{/* Header */}
<div className="mb-8">
<button
onClick={() => router.push('/dashboard/bookings')}
className="mb-4 flex items-center text-blue-600 hover:text-blue-800 font-medium"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Retour aux bookings
</button>
<div className="bg-white rounded-lg shadow-md p-6">
<h1 className="text-2xl font-bold text-gray-900 mb-2">Paiement de la commission</h1>
<p className="text-gray-600">
Finalisez votre booking en payant la commission de service
</p>
</div>
</div>
{/* Error */}
{error && (
<div className="mb-6 bg-red-50 border-2 border-red-200 rounded-lg p-4">
<div className="flex items-start">
<AlertTriangle className="h-5 w-5 mr-3 text-red-500 flex-shrink-0" />
<p className="text-red-700">{error}</p>
</div>
</div>
)}
{/* Booking Summary */}
<div className="bg-white rounded-lg shadow-lg p-6 mb-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Recapitulatif du booking</h2>
<div className="space-y-3 text-sm">
{booking.bookingNumber && (
<div className="flex justify-between">
<span className="text-gray-600">Numero :</span>
<span className="font-semibold text-gray-900">{booking.bookingNumber}</span>
</div>
)}
<div className="flex justify-between">
<span className="text-gray-600">Transporteur :</span>
<span className="font-semibold text-gray-900">{booking.carrierName}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Trajet :</span>
<span className="font-semibold text-gray-900">
{booking.origin} &rarr; {booking.destination}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Volume / Poids :</span>
<span className="font-semibold text-gray-900">
{booking.volumeCBM} CBM / {booking.weightKG} kg
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Transit :</span>
<span className="font-semibold text-gray-900">{booking.transitDays} jours</span>
</div>
<div className="flex justify-between border-t pt-3">
<span className="text-gray-600">Prix transport :</span>
<span className="font-bold text-gray-900">
{formatPrice(booking.priceEUR, 'EUR')}
</span>
</div>
</div>
</div>
{/* Commission Details */}
<div className="bg-white rounded-lg shadow-lg p-6 mb-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Commission de service</h2>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-4">
<div className="flex justify-between items-center">
<div>
<p className="text-sm text-gray-600">
Commission ({commissionRate}% du prix transport)
</p>
<p className="text-xs text-gray-500 mt-1">
{formatPrice(booking.priceEUR, 'EUR')} x {commissionRate}%
</p>
</div>
<p className="text-2xl font-bold text-blue-600">
{formatPrice(commissionAmount, 'EUR')}
</p>
</div>
</div>
<div className="bg-gray-50 rounded-lg p-3">
<div className="flex items-start space-x-2">
<CheckCircle className="h-4 w-4 text-green-500 mt-0.5 flex-shrink-0" />
<p className="text-xs text-gray-600">
Apres le paiement, votre demande sera envoyee par email au transporteur ({booking.carrierEmail}).
Vous recevrez une notification des que le transporteur repond.
</p>
</div>
</div>
</div>
{/* Payment Methods */}
<div className="space-y-4">
{/* Pay by Card (Stripe) */}
<button
onClick={handlePayByCard}
disabled={paying}
className="w-full bg-blue-600 text-white rounded-lg p-4 hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
>
<div className="flex items-center justify-center">
{paying ? (
<>
<Loader2 className="h-5 w-5 mr-3 animate-spin" />
Redirection vers Stripe...
</>
) : (
<>
<CreditCard className="h-5 w-5 mr-3" />
Payer {formatPrice(commissionAmount, 'EUR')} par carte
</>
)}
</div>
</button>
{/* Pay by Bank Transfer (informational) */}
<div className="bg-white rounded-lg shadow-lg p-6">
<div className="flex items-center mb-4">
<Building2 className="h-5 w-5 mr-3 text-gray-600" />
<h3 className="font-semibold text-gray-900">Payer par virement bancaire</h3>
</div>
<div className="bg-gray-50 rounded-lg p-4 space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-600">Beneficiaire :</span>
<span className="font-medium text-gray-900">XPEDITIS SAS</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">IBAN :</span>
<span className="font-mono text-gray-900">FR76 XXXX XXXX XXXX XXXX XXXX XXX</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">BIC :</span>
<span className="font-mono text-gray-900">XXXXXXXX</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Montant :</span>
<span className="font-bold text-gray-900">{formatPrice(commissionAmount, 'EUR')}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Reference :</span>
<span className="font-mono text-gray-900">{booking.bookingNumber || booking.id.slice(0, 8)}</span>
</div>
</div>
<p className="mt-3 text-xs text-gray-500">
Le traitement du virement peut prendre 1 a 3 jours ouvrables.
Votre booking sera active une fois le paiement recu et verifie.
</p>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,147 @@
/**
* Payment Success Page
*
* Displayed after successful Stripe payment. Confirms the payment and activates the booking.
*/
'use client';
import { useState, useEffect, useRef } from 'react';
import { useRouter, useParams, useSearchParams } from 'next/navigation';
import { CheckCircle, Loader2, AlertTriangle, Mail, ArrowRight } from 'lucide-react';
import { confirmBookingPayment } from '@/lib/api/bookings';
export default function PaymentSuccessPage() {
const router = useRouter();
const params = useParams();
const searchParams = useSearchParams();
const bookingId = params.id as string;
const sessionId = searchParams.get('session_id');
const [status, setStatus] = useState<'confirming' | 'success' | 'error'>('confirming');
const [error, setError] = useState<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 success page // Redirect to commission payment page
router.push(`/dashboard/bookings?success=true&id=${result.id}`); router.push(`/dashboard/booking/${result.id}/pay`);
} 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

@ -22,10 +22,15 @@ 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);
@ -48,16 +53,16 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
return null; return null;
} }
const navigation = [ const navigation: Array<{ name: string; href: string; icon: any; requiredFeature?: PlanFeature }> = [
{ name: 'Tableau de bord', href: '/dashboard', icon: BarChart3 }, { name: 'Tableau de bord', href: '/dashboard', icon: BarChart3, requiredFeature: 'dashboard' },
{ 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 }, { name: 'Suivi', href: '/dashboard/track-trace', icon: Search, requiredFeature: 'dashboard' },
{ name: 'Wiki Maritime', href: '/dashboard/wiki', icon: BookOpen }, { name: 'Wiki Maritime', href: '/dashboard/wiki', icon: BookOpen, requiredFeature: 'wiki' },
{ 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 }, { name: 'Utilisateurs', href: '/dashboard/settings/users', icon: Users, requiredFeature: 'user_management' as PlanFeature },
] : []), ] : []),
]; ];
@ -114,20 +119,26 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
{/* Navigation */} {/* 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 => {
<Link const locked = item.requiredFeature && !hasFeature(item.requiredFeature);
key={item.name} return (
href={item.href} <Link
className={`flex items-center px-4 py-3 text-sm font-medium rounded-lg transition-colors ${ key={item.name}
isActive(item.href) href={locked ? '/pricing' : item.href}
? 'bg-blue-50 text-blue-700' className={`flex items-center px-4 py-3 text-sm font-medium rounded-lg transition-colors ${
: 'text-gray-700 hover:bg-gray-100' locked
}`} ? 'text-gray-400 hover:bg-gray-50'
> : isActive(item.href)
<item.icon className="mr-3 h-5 w-5" /> ? 'bg-blue-50 text-blue-700'
{item.name} : 'text-gray-700 hover:bg-gray-100'
</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' && (
@ -145,9 +156,14 @@ 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">
<p className="text-sm font-medium text-gray-900 truncate"> <div className="flex items-center gap-1.5">
{user?.firstName} {user?.lastName} <p className="text-sm font-medium text-gray-900 truncate">
</p> {user?.firstName} {user?.lastName}
</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,12 +5,14 @@
'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,
@ -21,6 +23,7 @@ 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,
@ -39,6 +42,16 @@ 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

@ -0,0 +1,307 @@
'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

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

View File

@ -7,7 +7,8 @@
'use client'; 'use client';
import { useState, useRef, useEffect } from 'react'; import { useState, useRef, useEffect } from 'react';
import { Download, FileSpreadsheet, FileText, ChevronDown } from 'lucide-react'; import { Download, FileSpreadsheet, FileText, ChevronDown, Lock } from 'lucide-react';
import { useSubscription } from '@/lib/context/subscription-context';
interface ExportButtonProps<T> { interface ExportButtonProps<T> {
data: T[]; data: T[];
@ -26,6 +27,8 @@ 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);
@ -171,9 +174,12 @@ export default function ExportButton<T extends Record<string, any>>({
return ( return (
<div className="relative" ref={dropdownRef}> <div className="relative" ref={dropdownRef}>
<button <button
onClick={() => setIsOpen(!isOpen)} onClick={() => canExport ? setIsOpen(!isOpen) : window.location.href = '/pricing'}
disabled={disabled || data.length === 0 || isExporting} disabled={disabled || data.length === 0 || isExporting}
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" 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 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 ? (
<> <>
@ -200,15 +206,19 @@ export default function ExportButton<T extends Record<string, any>>({
</> </>
) : ( ) : (
<> <>
<Download className="mr-2 h-4 w-4" /> {canExport ? (
<Download className="mr-2 h-4 w-4" />
) : (
<Lock className="mr-2 h-4 w-4" />
)}
Exporter Exporter
<ChevronDown className="ml-2 h-4 w-4" /> {canExport && <ChevronDown className="ml-2 h-4 w-4" />}
</> </>
)} )}
</button> </button>
{/* Dropdown Menu */} {/* Dropdown Menu */}
{isOpen && !isExporting && ( {isOpen && !isExporting && canExport && (
<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

@ -117,7 +117,7 @@ export default function SubscriptionTab() {
}); });
const handleUpgrade = (plan: SubscriptionPlan) => { const handleUpgrade = (plan: SubscriptionPlan) => {
if (plan === 'FREE') return; if (plan === 'BRONZE') 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[] = ['FREE', 'STARTER', 'PRO', 'ENTERPRISE']; const planOrder: SubscriptionPlan[] = ['BRONZE', 'SILVER', 'GOLD', 'PLATINIUM'];
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 !== 'FREE' && ( {subscription.plan !== 'BRONZE' && (
<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">-20%</span> <span className="ml-1 text-xs text-green-600">-1 mois</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 === 'ENTERPRISE' {plan.plan === 'PLATINIUM'
? '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 !== 'ENTERPRISE' && plan.plan !== 'FREE' && ( {plan.plan !== 'PLATINIUM' && plan.plan !== 'BRONZE' && (
<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 === 'ENTERPRISE' ? ( ) : plan.plan === 'PLATINIUM' ? (
<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,6 +9,7 @@
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';
@ -30,10 +31,12 @@ export function Providers({ children }: { children: React.ReactNode }) {
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<AuthProvider> <AuthProvider>
<CookieProvider> <SubscriptionProvider>
{children} <CookieProvider>
<CookieConsent /> {children}
</CookieProvider> <CookieConsent />
</CookieProvider>
</SubscriptionProvider>
</AuthProvider> </AuthProvider>
</QueryClientProvider> </QueryClientProvider>
); );

View File

@ -0,0 +1,68 @@
'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

@ -0,0 +1,49 @@
'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

@ -51,7 +51,7 @@ export interface CsvBookingResponse {
primaryCurrency: string; primaryCurrency: string;
transitDays: number; transitDays: number;
containerType: string; containerType: string;
status: 'PENDING' | 'ACCEPTED' | 'REJECTED'; status: 'PENDING_PAYMENT' | 'PENDING' | 'ACCEPTED' | 'REJECTED' | 'CANCELLED';
documents: Array<{ documents: Array<{
type: string; type: string;
fileName: string; fileName: string;
@ -64,6 +64,14 @@ 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 {
@ -287,3 +295,26 @@ 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,
});
}

View File

@ -9,7 +9,16 @@ import { get, post } from './client';
/** /**
* Subscription plan types * Subscription plan types
*/ */
export type SubscriptionPlan = 'FREE' | 'STARTER' | 'PRO' | 'ENTERPRISE'; export type SubscriptionPlan = 'BRONZE' | 'SILVER' | 'GOLD' | 'PLATINIUM';
export type PlanFeature =
| 'dashboard'
| 'wiki'
| 'user_management'
| 'csv_export'
| 'api_access'
| 'custom_interface'
| 'dedicated_kam';
/** /**
* Subscription status types * Subscription status types
@ -38,6 +47,11 @@ 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[];
} }
@ -190,14 +204,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 'FREE': case 'BRONZE':
return 'bg-gray-100 text-gray-800'; return 'bg-orange-100 text-orange-800';
case 'STARTER': case 'SILVER':
return 'bg-blue-100 text-blue-800'; return 'bg-slate-100 text-slate-800';
case 'PRO': case 'GOLD':
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

@ -0,0 +1,77 @@
'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

@ -149,6 +149,9 @@ 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;