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

@ -81,7 +81,13 @@ export class DocumentWithUrlDto {
@ApiProperty({ @ApiProperty({
description: 'Document type', description: 'Document type',
enum: ['BILL_OF_LADING', 'PACKING_LIST', 'COMMERCIAL_INVOICE', 'CERTIFICATE_OF_ORIGIN', 'OTHER'], enum: [
'BILL_OF_LADING',
'PACKING_LIST',
'COMMERCIAL_INVOICE',
'CERTIFICATE_OF_ORIGIN',
'OTHER',
],
}) })
type: string; type: string;

View File

@ -3,7 +3,7 @@
* 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';
/** /**

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

@ -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

@ -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;