Compare commits
No commits in common. "420e52311cb7242f3a814ca3c4bfa5d162b463a5" and "1c6edb9d41072bea15e61b2ed4bd6354375ab2e8" have entirely different histories.
420e52311c
...
1c6edb9d41
11
CLAUDE.md
11
CLAUDE.md
@ -84,8 +84,6 @@ Docker-compose defaults (no `.env` changes needed for local dev):
|
|||||||
- **Redis**: password `xpeditis_redis_password`, port 6379
|
- **Redis**: password `xpeditis_redis_password`, port 6379
|
||||||
- **MinIO** (S3-compatible storage): `minioadmin:minioadmin`, API port 9000, console port 9001
|
- **MinIO** (S3-compatible storage): `minioadmin:minioadmin`, API port 9000, console port 9001
|
||||||
|
|
||||||
Frontend env var: `NEXT_PUBLIC_API_URL` (defaults to `http://localhost:4000`) — configured in `next.config.js`.
|
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
### Hexagonal Architecture (Backend)
|
### Hexagonal Architecture (Backend)
|
||||||
@ -188,14 +186,7 @@ Immutable, self-validating via static `create()`. E.g. `Money` supports USD, EUR
|
|||||||
- Separate mapper classes (`infrastructure/persistence/typeorm/mappers/`) with static `toOrm()`, `toDomain()`, `toDomainMany()` methods
|
- Separate mapper classes (`infrastructure/persistence/typeorm/mappers/`) with static `toOrm()`, `toDomain()`, `toDomainMany()` methods
|
||||||
|
|
||||||
### Frontend API Client
|
### Frontend API Client
|
||||||
Custom Fetch wrapper in `src/lib/api/client.ts` — exports `get()`, `post()`, `patch()`, `del()`, `upload()`, `download()`. Auto-refreshes JWT on 401. Tokens stored in localStorage **and synced to cookies** (`accessToken` cookie) so Next.js middleware can read them server-side. Per-module files (auth.ts, bookings.ts, rates.ts, etc.) import from client.
|
Custom Fetch wrapper in `src/lib/api/client.ts` — exports `get()`, `post()`, `patch()`, `del()`, `upload()`, `download()`. Auto-refreshes JWT on 401. Tokens stored in localStorage. Per-module files (auth.ts, bookings.ts, rates.ts, etc.) import from client.
|
||||||
|
|
||||||
### Route Protection (Middleware)
|
|
||||||
`apps/frontend/middleware.ts` checks the `accessToken` cookie to protect routes. Public paths are defined in two lists:
|
|
||||||
- `exactPublicPaths`: exact matches (e.g. `/`)
|
|
||||||
- `prefixPublicPaths`: prefix matches including sub-paths (e.g. `/login`, `/carrier`, `/about`, etc.)
|
|
||||||
|
|
||||||
All other routes redirect to `/login?redirect=<pathname>` when the cookie is absent.
|
|
||||||
|
|
||||||
### Application Decorators
|
### Application Decorators
|
||||||
- `@Public()` — skip JWT auth
|
- `@Public()` — skip JWT auth
|
||||||
|
|||||||
@ -93,9 +93,9 @@ STRIPE_SECRET_KEY=sk_test_your_stripe_secret_key
|
|||||||
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret
|
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret
|
||||||
|
|
||||||
# Stripe Price IDs (create these in Stripe Dashboard)
|
# Stripe Price IDs (create these in Stripe Dashboard)
|
||||||
STRIPE_SILVER_MONTHLY_PRICE_ID=price_silver_monthly
|
STRIPE_STARTER_MONTHLY_PRICE_ID=price_starter_monthly
|
||||||
STRIPE_SILVER_YEARLY_PRICE_ID=price_silver_yearly
|
STRIPE_STARTER_YEARLY_PRICE_ID=price_starter_yearly
|
||||||
STRIPE_GOLD_MONTHLY_PRICE_ID=price_gold_monthly
|
STRIPE_PRO_MONTHLY_PRICE_ID=price_pro_monthly
|
||||||
STRIPE_GOLD_YEARLY_PRICE_ID=price_gold_yearly
|
STRIPE_PRO_YEARLY_PRICE_ID=price_pro_yearly
|
||||||
STRIPE_PLATINIUM_MONTHLY_PRICE_ID=price_platinium_monthly
|
STRIPE_ENTERPRISE_MONTHLY_PRICE_ID=price_enterprise_monthly
|
||||||
STRIPE_PLATINIUM_YEARLY_PRICE_ID=price_platinium_yearly
|
STRIPE_ENTERPRISE_YEARLY_PRICE_ID=price_enterprise_yearly
|
||||||
|
|||||||
@ -60,12 +60,12 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard';
|
|||||||
// Stripe Configuration (optional for development)
|
// Stripe Configuration (optional for development)
|
||||||
STRIPE_SECRET_KEY: Joi.string().optional(),
|
STRIPE_SECRET_KEY: Joi.string().optional(),
|
||||||
STRIPE_WEBHOOK_SECRET: Joi.string().optional(),
|
STRIPE_WEBHOOK_SECRET: Joi.string().optional(),
|
||||||
STRIPE_SILVER_MONTHLY_PRICE_ID: Joi.string().optional(),
|
STRIPE_STARTER_MONTHLY_PRICE_ID: Joi.string().optional(),
|
||||||
STRIPE_SILVER_YEARLY_PRICE_ID: Joi.string().optional(),
|
STRIPE_STARTER_YEARLY_PRICE_ID: Joi.string().optional(),
|
||||||
STRIPE_GOLD_MONTHLY_PRICE_ID: Joi.string().optional(),
|
STRIPE_PRO_MONTHLY_PRICE_ID: Joi.string().optional(),
|
||||||
STRIPE_GOLD_YEARLY_PRICE_ID: Joi.string().optional(),
|
STRIPE_PRO_YEARLY_PRICE_ID: Joi.string().optional(),
|
||||||
STRIPE_PLATINIUM_MONTHLY_PRICE_ID: Joi.string().optional(),
|
STRIPE_ENTERPRISE_MONTHLY_PRICE_ID: Joi.string().optional(),
|
||||||
STRIPE_PLATINIUM_YEARLY_PRICE_ID: Joi.string().optional(),
|
STRIPE_ENTERPRISE_YEARLY_PRICE_ID: Joi.string().optional(),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
|
||||||
|
|
||||||
// Controller
|
// Controller
|
||||||
import { AdminController } from '../controllers/admin.controller';
|
import { AdminController } from '../controllers/admin.controller';
|
||||||
@ -19,13 +18,6 @@ import { TypeOrmCsvBookingRepository } from '@infrastructure/persistence/typeorm
|
|||||||
import { USER_REPOSITORY } from '@domain/ports/out/user.repository';
|
import { USER_REPOSITORY } from '@domain/ports/out/user.repository';
|
||||||
import { ORGANIZATION_REPOSITORY } from '@domain/ports/out/organization.repository';
|
import { ORGANIZATION_REPOSITORY } from '@domain/ports/out/organization.repository';
|
||||||
|
|
||||||
// SIRET verification
|
|
||||||
import { SIRET_VERIFICATION_PORT } from '@domain/ports/out/siret-verification.port';
|
|
||||||
import { PappersSiretAdapter } from '@infrastructure/external/pappers-siret.adapter';
|
|
||||||
|
|
||||||
// CSV Booking Service
|
|
||||||
import { CsvBookingsModule } from '../csv-bookings.module';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Admin Module
|
* Admin Module
|
||||||
*
|
*
|
||||||
@ -33,11 +25,7 @@ import { CsvBookingsModule } from '../csv-bookings.module';
|
|||||||
* All endpoints require ADMIN role.
|
* All endpoints require ADMIN role.
|
||||||
*/
|
*/
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [TypeOrmModule.forFeature([UserOrmEntity, OrganizationOrmEntity, CsvBookingOrmEntity])],
|
||||||
TypeOrmModule.forFeature([UserOrmEntity, OrganizationOrmEntity, CsvBookingOrmEntity]),
|
|
||||||
ConfigModule,
|
|
||||||
CsvBookingsModule,
|
|
||||||
],
|
|
||||||
controllers: [AdminController],
|
controllers: [AdminController],
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
@ -49,10 +37,6 @@ import { CsvBookingsModule } from '../csv-bookings.module';
|
|||||||
useClass: TypeOrmOrganizationRepository,
|
useClass: TypeOrmOrganizationRepository,
|
||||||
},
|
},
|
||||||
TypeOrmCsvBookingRepository,
|
TypeOrmCsvBookingRepository,
|
||||||
{
|
|
||||||
provide: SIRET_VERIFICATION_PORT,
|
|
||||||
useClass: PappersSiretAdapter,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AdminModule {}
|
export class AdminModule {}
|
||||||
|
|||||||
@ -25,8 +25,6 @@ export interface JwtPayload {
|
|||||||
email: string;
|
email: string;
|
||||||
role: string;
|
role: string;
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
plan?: string; // subscription plan (BRONZE, SILVER, GOLD, PLATINIUM)
|
|
||||||
planFeatures?: string[]; // plan feature flags
|
|
||||||
type: 'access' | 'refresh';
|
type: 'access' | 'refresh';
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -41,7 +39,7 @@ export class AuthService {
|
|||||||
private readonly organizationRepository: OrganizationRepository,
|
private readonly organizationRepository: OrganizationRepository,
|
||||||
private readonly jwtService: JwtService,
|
private readonly jwtService: JwtService,
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
private readonly subscriptionService: SubscriptionService
|
private readonly subscriptionService: SubscriptionService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -222,40 +220,11 @@ export class AuthService {
|
|||||||
* Generate access and refresh tokens
|
* Generate access and refresh tokens
|
||||||
*/
|
*/
|
||||||
private async generateTokens(user: User): Promise<{ accessToken: string; refreshToken: string }> {
|
private async generateTokens(user: User): Promise<{ accessToken: string; refreshToken: string }> {
|
||||||
// ADMIN users always get PLATINIUM plan with no expiration
|
|
||||||
let plan = 'BRONZE';
|
|
||||||
let planFeatures: string[] = [];
|
|
||||||
|
|
||||||
if (user.role === UserRole.ADMIN) {
|
|
||||||
plan = 'PLATINIUM';
|
|
||||||
planFeatures = [
|
|
||||||
'dashboard',
|
|
||||||
'wiki',
|
|
||||||
'user_management',
|
|
||||||
'csv_export',
|
|
||||||
'api_access',
|
|
||||||
'custom_interface',
|
|
||||||
'dedicated_kam',
|
|
||||||
];
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
const subscription = await this.subscriptionService.getOrCreateSubscription(
|
|
||||||
user.organizationId
|
|
||||||
);
|
|
||||||
plan = subscription.plan.value;
|
|
||||||
planFeatures = [...subscription.plan.planFeatures];
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.warn(`Failed to fetch subscription for JWT: ${error}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const accessPayload: JwtPayload = {
|
const accessPayload: JwtPayload = {
|
||||||
sub: user.id,
|
sub: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
role: user.role,
|
role: user.role,
|
||||||
organizationId: user.organizationId,
|
organizationId: user.organizationId,
|
||||||
plan,
|
|
||||||
planFeatures,
|
|
||||||
type: 'access',
|
type: 'access',
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -264,8 +233,6 @@ export class AuthService {
|
|||||||
email: user.email,
|
email: user.email,
|
||||||
role: user.role,
|
role: user.role,
|
||||||
organizationId: user.organizationId,
|
organizationId: user.organizationId,
|
||||||
plan,
|
|
||||||
planFeatures,
|
|
||||||
type: 'refresh',
|
type: 'refresh',
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -335,7 +302,6 @@ export class AuthService {
|
|||||||
name: organizationData.name,
|
name: organizationData.name,
|
||||||
type: organizationData.type,
|
type: organizationData.type,
|
||||||
scac: organizationData.scac,
|
scac: organizationData.scac,
|
||||||
siren: organizationData.siren,
|
|
||||||
address: {
|
address: {
|
||||||
street: organizationData.street,
|
street: organizationData.street,
|
||||||
city: organizationData.city,
|
city: organizationData.city,
|
||||||
|
|||||||
@ -6,18 +6,15 @@ import { BookingsController } from '../controllers/bookings.controller';
|
|||||||
import { BOOKING_REPOSITORY } from '@domain/ports/out/booking.repository';
|
import { BOOKING_REPOSITORY } from '@domain/ports/out/booking.repository';
|
||||||
import { RATE_QUOTE_REPOSITORY } from '@domain/ports/out/rate-quote.repository';
|
import { RATE_QUOTE_REPOSITORY } from '@domain/ports/out/rate-quote.repository';
|
||||||
import { USER_REPOSITORY } from '@domain/ports/out/user.repository';
|
import { USER_REPOSITORY } from '@domain/ports/out/user.repository';
|
||||||
import { SHIPMENT_COUNTER_PORT } from '@domain/ports/out/shipment-counter.port';
|
|
||||||
import { TypeOrmBookingRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-booking.repository';
|
import { TypeOrmBookingRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-booking.repository';
|
||||||
import { TypeOrmRateQuoteRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-rate-quote.repository';
|
import { TypeOrmRateQuoteRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-rate-quote.repository';
|
||||||
import { TypeOrmUserRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-user.repository';
|
import { TypeOrmUserRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-user.repository';
|
||||||
import { TypeOrmShipmentCounterRepository } from '../../infrastructure/persistence/typeorm/repositories/shipment-counter.repository';
|
|
||||||
|
|
||||||
// Import ORM entities
|
// Import ORM entities
|
||||||
import { BookingOrmEntity } from '../../infrastructure/persistence/typeorm/entities/booking.orm-entity';
|
import { BookingOrmEntity } from '../../infrastructure/persistence/typeorm/entities/booking.orm-entity';
|
||||||
import { ContainerOrmEntity } from '../../infrastructure/persistence/typeorm/entities/container.orm-entity';
|
import { ContainerOrmEntity } from '../../infrastructure/persistence/typeorm/entities/container.orm-entity';
|
||||||
import { RateQuoteOrmEntity } from '../../infrastructure/persistence/typeorm/entities/rate-quote.orm-entity';
|
import { RateQuoteOrmEntity } from '../../infrastructure/persistence/typeorm/entities/rate-quote.orm-entity';
|
||||||
import { UserOrmEntity } from '../../infrastructure/persistence/typeorm/entities/user.orm-entity';
|
import { UserOrmEntity } from '../../infrastructure/persistence/typeorm/entities/user.orm-entity';
|
||||||
import { CsvBookingOrmEntity } from '../../infrastructure/persistence/typeorm/entities/csv-booking.orm-entity';
|
|
||||||
|
|
||||||
// Import services and domain
|
// Import services and domain
|
||||||
import { BookingService } from '@domain/services/booking.service';
|
import { BookingService } from '@domain/services/booking.service';
|
||||||
@ -32,7 +29,6 @@ import { StorageModule } from '../../infrastructure/storage/storage.module';
|
|||||||
import { AuditModule } from '../audit/audit.module';
|
import { AuditModule } from '../audit/audit.module';
|
||||||
import { NotificationsModule } from '../notifications/notifications.module';
|
import { NotificationsModule } from '../notifications/notifications.module';
|
||||||
import { WebhooksModule } from '../webhooks/webhooks.module';
|
import { WebhooksModule } from '../webhooks/webhooks.module';
|
||||||
import { SubscriptionsModule } from '../subscriptions/subscriptions.module';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bookings Module
|
* Bookings Module
|
||||||
@ -51,7 +47,6 @@ import { SubscriptionsModule } from '../subscriptions/subscriptions.module';
|
|||||||
ContainerOrmEntity,
|
ContainerOrmEntity,
|
||||||
RateQuoteOrmEntity,
|
RateQuoteOrmEntity,
|
||||||
UserOrmEntity,
|
UserOrmEntity,
|
||||||
CsvBookingOrmEntity,
|
|
||||||
]),
|
]),
|
||||||
EmailModule,
|
EmailModule,
|
||||||
PdfModule,
|
PdfModule,
|
||||||
@ -59,7 +54,6 @@ import { SubscriptionsModule } from '../subscriptions/subscriptions.module';
|
|||||||
AuditModule,
|
AuditModule,
|
||||||
NotificationsModule,
|
NotificationsModule,
|
||||||
WebhooksModule,
|
WebhooksModule,
|
||||||
SubscriptionsModule,
|
|
||||||
],
|
],
|
||||||
controllers: [BookingsController],
|
controllers: [BookingsController],
|
||||||
providers: [
|
providers: [
|
||||||
@ -79,10 +73,6 @@ import { SubscriptionsModule } from '../subscriptions/subscriptions.module';
|
|||||||
provide: USER_REPOSITORY,
|
provide: USER_REPOSITORY,
|
||||||
useClass: TypeOrmUserRepository,
|
useClass: TypeOrmUserRepository,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
provide: SHIPMENT_COUNTER_PORT,
|
|
||||||
useClass: TypeOrmShipmentCounterRepository,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
exports: [BOOKING_REPOSITORY],
|
exports: [BOOKING_REPOSITORY],
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
Post,
|
|
||||||
Patch,
|
Patch,
|
||||||
Delete,
|
Delete,
|
||||||
Param,
|
Param,
|
||||||
@ -45,13 +44,6 @@ import { OrganizationResponseDto, OrganizationListResponseDto } from '../dto/org
|
|||||||
|
|
||||||
// CSV Booking imports
|
// CSV Booking imports
|
||||||
import { TypeOrmCsvBookingRepository } from '@infrastructure/persistence/typeorm/repositories/csv-booking.repository';
|
import { TypeOrmCsvBookingRepository } from '@infrastructure/persistence/typeorm/repositories/csv-booking.repository';
|
||||||
import { CsvBookingService } from '../services/csv-booking.service';
|
|
||||||
|
|
||||||
// SIRET verification imports
|
|
||||||
import {
|
|
||||||
SiretVerificationPort,
|
|
||||||
SIRET_VERIFICATION_PORT,
|
|
||||||
} from '@domain/ports/out/siret-verification.port';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Admin Controller
|
* Admin Controller
|
||||||
@ -73,10 +65,7 @@ export class AdminController {
|
|||||||
@Inject(USER_REPOSITORY) private readonly userRepository: UserRepository,
|
@Inject(USER_REPOSITORY) private readonly userRepository: UserRepository,
|
||||||
@Inject(ORGANIZATION_REPOSITORY)
|
@Inject(ORGANIZATION_REPOSITORY)
|
||||||
private readonly organizationRepository: OrganizationRepository,
|
private readonly organizationRepository: OrganizationRepository,
|
||||||
private readonly csvBookingRepository: TypeOrmCsvBookingRepository,
|
private readonly csvBookingRepository: TypeOrmCsvBookingRepository
|
||||||
private readonly csvBookingService: CsvBookingService,
|
|
||||||
@Inject(SIRET_VERIFICATION_PORT)
|
|
||||||
private readonly siretVerificationPort: SiretVerificationPort
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// ==================== USERS ENDPOINTS ====================
|
// ==================== USERS ENDPOINTS ====================
|
||||||
@ -340,163 +329,6 @@ export class AdminController {
|
|||||||
return OrganizationMapper.toDto(organization);
|
return OrganizationMapper.toDto(organization);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Verify SIRET number for an organization (admin only)
|
|
||||||
*
|
|
||||||
* Calls Pappers API to verify the SIRET, then marks the organization as verified.
|
|
||||||
*/
|
|
||||||
@Post('organizations/:id/verify-siret')
|
|
||||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Verify organization SIRET (Admin only)',
|
|
||||||
description:
|
|
||||||
'Verify the SIRET number of an organization via Pappers API and mark it as verified. Required before the organization can make purchases.',
|
|
||||||
})
|
|
||||||
@ApiParam({
|
|
||||||
name: 'id',
|
|
||||||
description: 'Organization ID (UUID)',
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: HttpStatus.OK,
|
|
||||||
description: 'SIRET verification result',
|
|
||||||
schema: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
verified: { type: 'boolean' },
|
|
||||||
companyName: { type: 'string' },
|
|
||||||
address: { type: 'string' },
|
|
||||||
message: { type: 'string' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@ApiNotFoundResponse({
|
|
||||||
description: 'Organization not found',
|
|
||||||
})
|
|
||||||
async verifySiret(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: UserPayload) {
|
|
||||||
this.logger.log(`[ADMIN: ${user.email}] Verifying SIRET for organization: ${id}`);
|
|
||||||
|
|
||||||
const organization = await this.organizationRepository.findById(id);
|
|
||||||
if (!organization) {
|
|
||||||
throw new NotFoundException(`Organization ${id} not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const siret = organization.siret;
|
|
||||||
if (!siret) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
'Organization has no SIRET number. Please set a SIRET number before verification.'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await this.siretVerificationPort.verify(siret);
|
|
||||||
|
|
||||||
if (!result.valid) {
|
|
||||||
this.logger.warn(`[ADMIN] SIRET verification failed for ${siret}`);
|
|
||||||
return {
|
|
||||||
verified: false,
|
|
||||||
message: `Le numero SIRET ${siret} est invalide ou introuvable.`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mark as verified and save
|
|
||||||
organization.markSiretVerified();
|
|
||||||
await this.organizationRepository.update(organization);
|
|
||||||
|
|
||||||
this.logger.log(`[ADMIN] SIRET verified successfully for organization: ${id}`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
verified: true,
|
|
||||||
companyName: result.companyName,
|
|
||||||
address: result.address,
|
|
||||||
message: `SIRET ${siret} verifie avec succes.`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Manually approve SIRET/SIREN for an organization (admin only)
|
|
||||||
*
|
|
||||||
* Marks the organization's SIRET as verified without calling the external API.
|
|
||||||
*/
|
|
||||||
@Post('organizations/:id/approve-siret')
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Approve SIRET/SIREN (Admin only)',
|
|
||||||
description:
|
|
||||||
'Manually approve the SIRET/SIREN of an organization. Marks it as verified without calling Pappers API.',
|
|
||||||
})
|
|
||||||
@ApiParam({ name: 'id', description: 'Organization ID (UUID)' })
|
|
||||||
@ApiResponse({
|
|
||||||
status: HttpStatus.OK,
|
|
||||||
description: 'SIRET approved successfully',
|
|
||||||
})
|
|
||||||
@ApiNotFoundResponse({ description: 'Organization not found' })
|
|
||||||
async approveSiret(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: UserPayload) {
|
|
||||||
this.logger.log(`[ADMIN: ${user.email}] Manually approving SIRET for organization: ${id}`);
|
|
||||||
|
|
||||||
const organization = await this.organizationRepository.findById(id);
|
|
||||||
if (!organization) {
|
|
||||||
throw new NotFoundException(`Organization ${id} not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!organization.siret && !organization.siren) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
"L'organisation n'a ni SIRET ni SIREN. Veuillez en renseigner un avant l'approbation."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
organization.markSiretVerified();
|
|
||||||
await this.organizationRepository.update(organization);
|
|
||||||
|
|
||||||
this.logger.log(`[ADMIN] SIRET manually approved for organization: ${id}`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
approved: true,
|
|
||||||
message: 'SIRET/SIREN approuve manuellement avec succes.',
|
|
||||||
organizationId: id,
|
|
||||||
organizationName: organization.name,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reject SIRET/SIREN for an organization (admin only)
|
|
||||||
*
|
|
||||||
* Resets the verification flag to false.
|
|
||||||
*/
|
|
||||||
@Post('organizations/:id/reject-siret')
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Reject SIRET/SIREN (Admin only)',
|
|
||||||
description:
|
|
||||||
'Reject the SIRET/SIREN of an organization. Resets the verification status to unverified.',
|
|
||||||
})
|
|
||||||
@ApiParam({ name: 'id', description: 'Organization ID (UUID)' })
|
|
||||||
@ApiResponse({
|
|
||||||
status: HttpStatus.OK,
|
|
||||||
description: 'SIRET rejected successfully',
|
|
||||||
})
|
|
||||||
@ApiNotFoundResponse({ description: 'Organization not found' })
|
|
||||||
async rejectSiret(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: UserPayload) {
|
|
||||||
this.logger.log(`[ADMIN: ${user.email}] Rejecting SIRET for organization: ${id}`);
|
|
||||||
|
|
||||||
const organization = await this.organizationRepository.findById(id);
|
|
||||||
if (!organization) {
|
|
||||||
throw new NotFoundException(`Organization ${id} not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset SIRET verification to false by updating the SIRET (which resets siretVerified)
|
|
||||||
// If no SIRET, just update directly
|
|
||||||
if (organization.siret) {
|
|
||||||
organization.updateSiret(organization.siret); // This resets siretVerified to false
|
|
||||||
}
|
|
||||||
await this.organizationRepository.update(organization);
|
|
||||||
|
|
||||||
this.logger.log(`[ADMIN] SIRET rejected for organization: ${id}`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
rejected: true,
|
|
||||||
message: "SIRET/SIREN rejete. L'organisation ne pourra pas effectuer d'achats.",
|
|
||||||
organizationId: id,
|
|
||||||
organizationName: organization.name,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== CSV BOOKINGS ENDPOINTS ====================
|
// ==================== CSV BOOKINGS ENDPOINTS ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -608,28 +440,6 @@ export class AdminController {
|
|||||||
return this.csvBookingToDto(updatedBooking);
|
return this.csvBookingToDto(updatedBooking);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate bank transfer for a booking (admin only)
|
|
||||||
*
|
|
||||||
* Transitions booking from PENDING_BANK_TRANSFER → PENDING and sends email to carrier
|
|
||||||
*/
|
|
||||||
@Post('bookings/:id/validate-transfer')
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Validate bank transfer (Admin only)',
|
|
||||||
description:
|
|
||||||
'Admin confirms that the bank wire transfer has been received. Activates the booking and sends email to carrier.',
|
|
||||||
})
|
|
||||||
@ApiParam({ name: 'id', description: 'Booking ID (UUID)' })
|
|
||||||
@ApiResponse({ status: 200, description: 'Bank transfer validated, booking activated' })
|
|
||||||
@ApiNotFoundResponse({ description: 'Booking not found' })
|
|
||||||
async validateBankTransfer(
|
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
|
||||||
@CurrentUser() user: UserPayload
|
|
||||||
) {
|
|
||||||
this.logger.log(`[ADMIN: ${user.email}] Validating bank transfer for booking: ${id}`);
|
|
||||||
return this.csvBookingService.validateBankTransfer(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete csv booking (admin only)
|
* Delete csv booking (admin only)
|
||||||
*/
|
*/
|
||||||
@ -673,7 +483,6 @@ export class AdminController {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id: booking.id,
|
id: booking.id,
|
||||||
bookingNumber: booking.bookingNumber || null,
|
|
||||||
userId: booking.userId,
|
userId: booking.userId,
|
||||||
organizationId: booking.organizationId,
|
organizationId: booking.organizationId,
|
||||||
carrierName: booking.carrierName,
|
carrierName: booking.carrierName,
|
||||||
|
|||||||
@ -53,12 +53,6 @@ import { NotificationService } from '../services/notification.service';
|
|||||||
import { NotificationsGateway } from '../gateways/notifications.gateway';
|
import { NotificationsGateway } from '../gateways/notifications.gateway';
|
||||||
import { WebhookService } from '../services/webhook.service';
|
import { WebhookService } from '../services/webhook.service';
|
||||||
import { WebhookEvent } from '@domain/entities/webhook.entity';
|
import { WebhookEvent } from '@domain/entities/webhook.entity';
|
||||||
import {
|
|
||||||
ShipmentCounterPort,
|
|
||||||
SHIPMENT_COUNTER_PORT,
|
|
||||||
} from '@domain/ports/out/shipment-counter.port';
|
|
||||||
import { SubscriptionService } from '../services/subscription.service';
|
|
||||||
import { ShipmentLimitExceededException } from '@domain/exceptions/shipment-limit-exceeded.exception';
|
|
||||||
|
|
||||||
@ApiTags('Bookings')
|
@ApiTags('Bookings')
|
||||||
@Controller('bookings')
|
@Controller('bookings')
|
||||||
@ -76,9 +70,7 @@ export class BookingsController {
|
|||||||
private readonly auditService: AuditService,
|
private readonly auditService: AuditService,
|
||||||
private readonly notificationService: NotificationService,
|
private readonly notificationService: NotificationService,
|
||||||
private readonly notificationsGateway: NotificationsGateway,
|
private readonly notificationsGateway: NotificationsGateway,
|
||||||
private readonly webhookService: WebhookService,
|
private readonly webhookService: WebhookService
|
||||||
@Inject(SHIPMENT_COUNTER_PORT) private readonly shipmentCounter: ShipmentCounterPort,
|
|
||||||
private readonly subscriptionService: SubscriptionService
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@ -113,22 +105,6 @@ export class BookingsController {
|
|||||||
): Promise<BookingResponseDto> {
|
): Promise<BookingResponseDto> {
|
||||||
this.logger.log(`[User: ${user.email}] Creating booking for rate quote: ${dto.rateQuoteId}`);
|
this.logger.log(`[User: ${user.email}] Creating booking for rate quote: ${dto.rateQuoteId}`);
|
||||||
|
|
||||||
// Check shipment limit for Bronze plan
|
|
||||||
const subscription = await this.subscriptionService.getOrCreateSubscription(
|
|
||||||
user.organizationId
|
|
||||||
);
|
|
||||||
const maxShipments = subscription.plan.maxShipmentsPerYear;
|
|
||||||
if (maxShipments !== -1) {
|
|
||||||
const currentYear = new Date().getFullYear();
|
|
||||||
const count = await this.shipmentCounter.countShipmentsForOrganizationInYear(
|
|
||||||
user.organizationId,
|
|
||||||
currentYear
|
|
||||||
);
|
|
||||||
if (count >= maxShipments) {
|
|
||||||
throw new ShipmentLimitExceededException(user.organizationId, count, maxShipments);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Convert DTO to domain input, using authenticated user's data
|
// Convert DTO to domain input, using authenticated user's data
|
||||||
const input = {
|
const input = {
|
||||||
@ -480,16 +456,9 @@ export class BookingsController {
|
|||||||
|
|
||||||
// Filter out bookings or rate quotes that are null
|
// Filter out bookings or rate quotes that are null
|
||||||
const bookingsWithQuotes = bookingsWithQuotesRaw.filter(
|
const bookingsWithQuotes = bookingsWithQuotesRaw.filter(
|
||||||
(
|
(item): item is { booking: NonNullable<typeof item.booking>; rateQuote: NonNullable<typeof item.rateQuote> } =>
|
||||||
item
|
item.booking !== null && item.booking !== undefined &&
|
||||||
): item is {
|
item.rateQuote !== null && item.rateQuote !== undefined
|
||||||
booking: NonNullable<typeof item.booking>;
|
|
||||||
rateQuote: NonNullable<typeof item.rateQuote>;
|
|
||||||
} =>
|
|
||||||
item.booking !== null &&
|
|
||||||
item.booking !== undefined &&
|
|
||||||
item.rateQuote !== null &&
|
|
||||||
item.rateQuote !== undefined
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Convert to DTOs
|
// Convert to DTOs
|
||||||
|
|||||||
@ -12,12 +12,9 @@ import {
|
|||||||
UploadedFiles,
|
UploadedFiles,
|
||||||
Request,
|
Request,
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
ForbiddenException,
|
|
||||||
ParseIntPipe,
|
ParseIntPipe,
|
||||||
DefaultValuePipe,
|
DefaultValuePipe,
|
||||||
Inject,
|
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import { FilesInterceptor } from '@nestjs/platform-express';
|
import { FilesInterceptor } from '@nestjs/platform-express';
|
||||||
import {
|
import {
|
||||||
ApiTags,
|
ApiTags,
|
||||||
@ -32,16 +29,6 @@ import {
|
|||||||
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
||||||
import { Public } from '../decorators/public.decorator';
|
import { Public } from '../decorators/public.decorator';
|
||||||
import { CsvBookingService } from '../services/csv-booking.service';
|
import { CsvBookingService } from '../services/csv-booking.service';
|
||||||
import { SubscriptionService } from '../services/subscription.service';
|
|
||||||
import {
|
|
||||||
ShipmentCounterPort,
|
|
||||||
SHIPMENT_COUNTER_PORT,
|
|
||||||
} from '@domain/ports/out/shipment-counter.port';
|
|
||||||
import {
|
|
||||||
OrganizationRepository,
|
|
||||||
ORGANIZATION_REPOSITORY,
|
|
||||||
} from '@domain/ports/out/organization.repository';
|
|
||||||
import { ShipmentLimitExceededException } from '@domain/exceptions/shipment-limit-exceeded.exception';
|
|
||||||
import {
|
import {
|
||||||
CreateCsvBookingDto,
|
CreateCsvBookingDto,
|
||||||
CsvBookingResponseDto,
|
CsvBookingResponseDto,
|
||||||
@ -61,15 +48,7 @@ import {
|
|||||||
@ApiTags('CSV Bookings')
|
@ApiTags('CSV Bookings')
|
||||||
@Controller('csv-bookings')
|
@Controller('csv-bookings')
|
||||||
export class CsvBookingsController {
|
export class CsvBookingsController {
|
||||||
constructor(
|
constructor(private readonly csvBookingService: CsvBookingService) {}
|
||||||
private readonly csvBookingService: CsvBookingService,
|
|
||||||
private readonly subscriptionService: SubscriptionService,
|
|
||||||
private readonly configService: ConfigService,
|
|
||||||
@Inject(SHIPMENT_COUNTER_PORT)
|
|
||||||
private readonly shipmentCounter: ShipmentCounterPort,
|
|
||||||
@Inject(ORGANIZATION_REPOSITORY)
|
|
||||||
private readonly organizationRepository: OrganizationRepository
|
|
||||||
) {}
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// STATIC ROUTES (must come FIRST)
|
// STATIC ROUTES (must come FIRST)
|
||||||
@ -81,6 +60,7 @@ export class CsvBookingsController {
|
|||||||
* POST /api/v1/csv-bookings
|
* POST /api/v1/csv-bookings
|
||||||
*/
|
*/
|
||||||
@Post()
|
@Post()
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@UseInterceptors(FilesInterceptor('documents', 10))
|
@UseInterceptors(FilesInterceptor('documents', 10))
|
||||||
@ApiConsumes('multipart/form-data')
|
@ApiConsumes('multipart/form-data')
|
||||||
@ -164,23 +144,6 @@ export class CsvBookingsController {
|
|||||||
const userId = req.user.id;
|
const userId = req.user.id;
|
||||||
const organizationId = req.user.organizationId;
|
const organizationId = req.user.organizationId;
|
||||||
|
|
||||||
// ADMIN users bypass shipment limits
|
|
||||||
if (req.user.role !== 'ADMIN') {
|
|
||||||
// Check shipment limit (Bronze plan = 12/year)
|
|
||||||
const subscription = await this.subscriptionService.getOrCreateSubscription(organizationId);
|
|
||||||
const maxShipments = subscription.plan.maxShipmentsPerYear;
|
|
||||||
if (maxShipments !== -1) {
|
|
||||||
const currentYear = new Date().getFullYear();
|
|
||||||
const count = await this.shipmentCounter.countShipmentsForOrganizationInYear(
|
|
||||||
organizationId,
|
|
||||||
currentYear
|
|
||||||
);
|
|
||||||
if (count >= maxShipments) {
|
|
||||||
throw new ShipmentLimitExceededException(organizationId, count, maxShipments);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert string values to numbers (multipart/form-data sends everything as strings)
|
// Convert string values to numbers (multipart/form-data sends everything as strings)
|
||||||
const sanitizedDto: CreateCsvBookingDto = {
|
const sanitizedDto: CreateCsvBookingDto = {
|
||||||
...dto,
|
...dto,
|
||||||
@ -378,126 +341,6 @@ export class CsvBookingsController {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Create Stripe Checkout session for commission payment
|
|
||||||
*
|
|
||||||
* POST /api/v1/csv-bookings/:id/pay
|
|
||||||
*/
|
|
||||||
@Post(':id/pay')
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Pay commission for a booking',
|
|
||||||
description:
|
|
||||||
'Creates a Stripe Checkout session for the commission payment. Returns the Stripe session URL to redirect the user to.',
|
|
||||||
})
|
|
||||||
@ApiParam({ name: 'id', description: 'Booking ID (UUID)' })
|
|
||||||
@ApiResponse({
|
|
||||||
status: 200,
|
|
||||||
description: 'Stripe checkout session created',
|
|
||||||
schema: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
sessionUrl: { type: 'string' },
|
|
||||||
sessionId: { type: 'string' },
|
|
||||||
commissionAmountEur: { type: 'number' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@ApiResponse({ status: 400, description: 'Booking not in PENDING_PAYMENT status' })
|
|
||||||
@ApiResponse({ status: 404, description: 'Booking not found' })
|
|
||||||
async payCommission(@Param('id') id: string, @Request() req: any) {
|
|
||||||
const userId = req.user.id;
|
|
||||||
const userEmail = req.user.email;
|
|
||||||
const organizationId = req.user.organizationId;
|
|
||||||
const frontendUrl = this.configService.get<string>('FRONTEND_URL') || 'http://localhost:3000';
|
|
||||||
|
|
||||||
// ADMIN users bypass SIRET verification
|
|
||||||
if (req.user.role !== 'ADMIN') {
|
|
||||||
// SIRET verification gate: organization must have a verified SIRET before paying
|
|
||||||
const organization = await this.organizationRepository.findById(organizationId);
|
|
||||||
if (!organization || !organization.siretVerified) {
|
|
||||||
throw new ForbiddenException(
|
|
||||||
'Le numero SIRET de votre organisation doit etre verifie par un administrateur avant de pouvoir effectuer un paiement. Contactez votre administrateur.'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return await this.csvBookingService.createCommissionPayment(id, userId, userEmail, frontendUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Confirm commission payment after Stripe redirect
|
|
||||||
*
|
|
||||||
* POST /api/v1/csv-bookings/:id/confirm-payment
|
|
||||||
*/
|
|
||||||
@Post(':id/confirm-payment')
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Confirm commission payment',
|
|
||||||
description:
|
|
||||||
'Called after Stripe payment success. Verifies the payment, updates booking to PENDING, sends email to carrier.',
|
|
||||||
})
|
|
||||||
@ApiParam({ name: 'id', description: 'Booking ID (UUID)' })
|
|
||||||
@ApiBody({
|
|
||||||
schema: {
|
|
||||||
type: 'object',
|
|
||||||
required: ['sessionId'],
|
|
||||||
properties: {
|
|
||||||
sessionId: { type: 'string', description: 'Stripe Checkout session ID' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: 200,
|
|
||||||
description: 'Payment confirmed, booking activated',
|
|
||||||
type: CsvBookingResponseDto,
|
|
||||||
})
|
|
||||||
@ApiResponse({ status: 400, description: 'Payment not completed or session mismatch' })
|
|
||||||
@ApiResponse({ status: 404, description: 'Booking not found' })
|
|
||||||
async confirmPayment(
|
|
||||||
@Param('id') id: string,
|
|
||||||
@Body('sessionId') sessionId: string,
|
|
||||||
@Request() req: any
|
|
||||||
): Promise<CsvBookingResponseDto> {
|
|
||||||
if (!sessionId) {
|
|
||||||
throw new BadRequestException('sessionId is required');
|
|
||||||
}
|
|
||||||
|
|
||||||
const userId = req.user.id;
|
|
||||||
return await this.csvBookingService.confirmCommissionPayment(id, sessionId, userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Declare bank transfer — user confirms they have sent the wire transfer
|
|
||||||
*
|
|
||||||
* POST /api/v1/csv-bookings/:id/declare-transfer
|
|
||||||
*/
|
|
||||||
@Post(':id/declare-transfer')
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Declare bank transfer',
|
|
||||||
description:
|
|
||||||
'User confirms they have sent the bank wire transfer. Transitions booking to PENDING_BANK_TRANSFER awaiting admin validation.',
|
|
||||||
})
|
|
||||||
@ApiParam({ name: 'id', description: 'Booking ID (UUID)' })
|
|
||||||
@ApiResponse({
|
|
||||||
status: 200,
|
|
||||||
description: 'Bank transfer declared, booking awaiting admin validation',
|
|
||||||
type: CsvBookingResponseDto,
|
|
||||||
})
|
|
||||||
@ApiResponse({ status: 400, description: 'Booking not in PENDING_PAYMENT status' })
|
|
||||||
@ApiResponse({ status: 404, description: 'Booking not found' })
|
|
||||||
async declareTransfer(
|
|
||||||
@Param('id') id: string,
|
|
||||||
@Request() req: any
|
|
||||||
): Promise<CsvBookingResponseDto> {
|
|
||||||
const userId = req.user.id;
|
|
||||||
return await this.csvBookingService.declareBankTransfer(id, userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// PARAMETERIZED ROUTES (must come LAST)
|
// PARAMETERIZED ROUTES (must come LAST)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@ -22,7 +22,12 @@ import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
|||||||
import { CurrentUser } from '../decorators/current-user.decorator';
|
import { CurrentUser } from '../decorators/current-user.decorator';
|
||||||
import { UserPayload } from '../decorators/current-user.decorator';
|
import { UserPayload } from '../decorators/current-user.decorator';
|
||||||
import { GDPRService } from '../services/gdpr.service';
|
import { GDPRService } from '../services/gdpr.service';
|
||||||
import { UpdateConsentDto, ConsentResponseDto, WithdrawConsentDto } from '../dto/consent.dto';
|
import {
|
||||||
|
UpdateConsentDto,
|
||||||
|
ConsentResponseDto,
|
||||||
|
WithdrawConsentDto,
|
||||||
|
ConsentSuccessDto,
|
||||||
|
} from '../dto/consent.dto';
|
||||||
|
|
||||||
@ApiTags('GDPR')
|
@ApiTags('GDPR')
|
||||||
@Controller('gdpr')
|
@Controller('gdpr')
|
||||||
|
|||||||
@ -71,8 +71,7 @@ export class InvitationsController {
|
|||||||
dto.lastName,
|
dto.lastName,
|
||||||
dto.role as unknown as UserRole,
|
dto.role as unknown as UserRole,
|
||||||
user.organizationId,
|
user.organizationId,
|
||||||
user.id,
|
user.id
|
||||||
user.role
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -22,8 +22,6 @@ import {
|
|||||||
Headers,
|
Headers,
|
||||||
RawBodyRequest,
|
RawBodyRequest,
|
||||||
Req,
|
Req,
|
||||||
Inject,
|
|
||||||
ForbiddenException,
|
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
ApiTags,
|
ApiTags,
|
||||||
@ -49,21 +47,13 @@ import { RolesGuard } from '../guards/roles.guard';
|
|||||||
import { Roles } from '../decorators/roles.decorator';
|
import { Roles } from '../decorators/roles.decorator';
|
||||||
import { CurrentUser, UserPayload } from '../decorators/current-user.decorator';
|
import { CurrentUser, UserPayload } from '../decorators/current-user.decorator';
|
||||||
import { Public } from '../decorators/public.decorator';
|
import { Public } from '../decorators/public.decorator';
|
||||||
import {
|
|
||||||
OrganizationRepository,
|
|
||||||
ORGANIZATION_REPOSITORY,
|
|
||||||
} from '@domain/ports/out/organization.repository';
|
|
||||||
|
|
||||||
@ApiTags('Subscriptions')
|
@ApiTags('Subscriptions')
|
||||||
@Controller('subscriptions')
|
@Controller('subscriptions')
|
||||||
export class SubscriptionsController {
|
export class SubscriptionsController {
|
||||||
private readonly logger = new Logger(SubscriptionsController.name);
|
private readonly logger = new Logger(SubscriptionsController.name);
|
||||||
|
|
||||||
constructor(
|
constructor(private readonly subscriptionService: SubscriptionService) {}
|
||||||
private readonly subscriptionService: SubscriptionService,
|
|
||||||
@Inject(ORGANIZATION_REPOSITORY)
|
|
||||||
private readonly organizationRepository: OrganizationRepository
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get subscription overview for current organization
|
* Get subscription overview for current organization
|
||||||
@ -87,10 +77,10 @@ export class SubscriptionsController {
|
|||||||
description: 'Forbidden - requires admin or manager role',
|
description: 'Forbidden - requires admin or manager role',
|
||||||
})
|
})
|
||||||
async getSubscriptionOverview(
|
async getSubscriptionOverview(
|
||||||
@CurrentUser() user: UserPayload
|
@CurrentUser() user: UserPayload,
|
||||||
): Promise<SubscriptionOverviewResponseDto> {
|
): Promise<SubscriptionOverviewResponseDto> {
|
||||||
this.logger.log(`[User: ${user.email}] Getting subscription overview`);
|
this.logger.log(`[User: ${user.email}] Getting subscription overview`);
|
||||||
return this.subscriptionService.getSubscriptionOverview(user.organizationId, user.role);
|
return this.subscriptionService.getSubscriptionOverview(user.organizationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -136,7 +126,7 @@ export class SubscriptionsController {
|
|||||||
})
|
})
|
||||||
async canInvite(@CurrentUser() user: UserPayload): Promise<CanInviteResponseDto> {
|
async canInvite(@CurrentUser() user: UserPayload): Promise<CanInviteResponseDto> {
|
||||||
this.logger.log(`[User: ${user.email}] Checking license availability`);
|
this.logger.log(`[User: ${user.email}] Checking license availability`);
|
||||||
return this.subscriptionService.canInviteUser(user.organizationId, user.role);
|
return this.subscriptionService.canInviteUser(user.organizationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -149,7 +139,8 @@ export class SubscriptionsController {
|
|||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Create checkout session',
|
summary: 'Create checkout session',
|
||||||
description: 'Create a Stripe Checkout session for upgrading subscription. Admin/Manager only.',
|
description:
|
||||||
|
'Create a Stripe Checkout session for upgrading subscription. Admin/Manager only.',
|
||||||
})
|
})
|
||||||
@ApiResponse({
|
@ApiResponse({
|
||||||
status: 200,
|
status: 200,
|
||||||
@ -166,22 +157,14 @@ export class SubscriptionsController {
|
|||||||
})
|
})
|
||||||
async createCheckoutSession(
|
async createCheckoutSession(
|
||||||
@Body() dto: CreateCheckoutSessionDto,
|
@Body() dto: CreateCheckoutSessionDto,
|
||||||
@CurrentUser() user: UserPayload
|
@CurrentUser() user: UserPayload,
|
||||||
): Promise<CheckoutSessionResponseDto> {
|
): Promise<CheckoutSessionResponseDto> {
|
||||||
this.logger.log(`[User: ${user.email}] Creating checkout session for plan: ${dto.plan}`);
|
this.logger.log(`[User: ${user.email}] Creating checkout session for plan: ${dto.plan}`);
|
||||||
|
return this.subscriptionService.createCheckoutSession(
|
||||||
// ADMIN users bypass all payment restrictions
|
user.organizationId,
|
||||||
if (user.role !== 'ADMIN') {
|
user.id,
|
||||||
// SIRET verification gate: organization must have a verified SIRET before purchasing
|
dto,
|
||||||
const organization = await this.organizationRepository.findById(user.organizationId);
|
);
|
||||||
if (!organization || !organization.siretVerified) {
|
|
||||||
throw new ForbiddenException(
|
|
||||||
'Le numero SIRET de votre organisation doit etre verifie par un administrateur avant de pouvoir effectuer un achat. Contactez votre administrateur.'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.subscriptionService.createCheckoutSession(user.organizationId, user.id, dto);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -212,7 +195,7 @@ export class SubscriptionsController {
|
|||||||
})
|
})
|
||||||
async createPortalSession(
|
async createPortalSession(
|
||||||
@Body() dto: CreatePortalSessionDto,
|
@Body() dto: CreatePortalSessionDto,
|
||||||
@CurrentUser() user: UserPayload
|
@CurrentUser() user: UserPayload,
|
||||||
): Promise<PortalSessionResponseDto> {
|
): Promise<PortalSessionResponseDto> {
|
||||||
this.logger.log(`[User: ${user.email}] Creating portal session`);
|
this.logger.log(`[User: ${user.email}] Creating portal session`);
|
||||||
return this.subscriptionService.createPortalSession(user.organizationId, dto);
|
return this.subscriptionService.createPortalSession(user.organizationId, dto);
|
||||||
@ -247,10 +230,10 @@ export class SubscriptionsController {
|
|||||||
})
|
})
|
||||||
async syncFromStripe(
|
async syncFromStripe(
|
||||||
@Body() dto: SyncSubscriptionDto,
|
@Body() dto: SyncSubscriptionDto,
|
||||||
@CurrentUser() user: UserPayload
|
@CurrentUser() user: UserPayload,
|
||||||
): Promise<SubscriptionOverviewResponseDto> {
|
): Promise<SubscriptionOverviewResponseDto> {
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`[User: ${user.email}] Syncing subscription from Stripe${dto.sessionId ? ` (sessionId: ${dto.sessionId})` : ''}`
|
`[User: ${user.email}] Syncing subscription from Stripe${dto.sessionId ? ` (sessionId: ${dto.sessionId})` : ''}`,
|
||||||
);
|
);
|
||||||
return this.subscriptionService.syncFromStripe(user.organizationId, dto.sessionId);
|
return this.subscriptionService.syncFromStripe(user.organizationId, dto.sessionId);
|
||||||
}
|
}
|
||||||
@ -264,7 +247,7 @@ export class SubscriptionsController {
|
|||||||
@ApiExcludeEndpoint()
|
@ApiExcludeEndpoint()
|
||||||
async handleWebhook(
|
async handleWebhook(
|
||||||
@Headers('stripe-signature') signature: string,
|
@Headers('stripe-signature') signature: string,
|
||||||
@Req() req: RawBodyRequest<Request>
|
@Req() req: RawBodyRequest<Request>,
|
||||||
): Promise<{ received: boolean }> {
|
): Promise<{ received: boolean }> {
|
||||||
const rawBody = req.rawBody;
|
const rawBody = req.rawBody;
|
||||||
if (!rawBody) {
|
if (!rawBody) {
|
||||||
|
|||||||
@ -44,10 +44,8 @@ import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.reposito
|
|||||||
import { User, UserRole as DomainUserRole } from '@domain/entities/user.entity';
|
import { User, UserRole as DomainUserRole } from '@domain/entities/user.entity';
|
||||||
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
||||||
import { RolesGuard } from '../guards/roles.guard';
|
import { RolesGuard } from '../guards/roles.guard';
|
||||||
import { FeatureFlagGuard } from '../guards/feature-flag.guard';
|
|
||||||
import { CurrentUser, UserPayload } from '../decorators/current-user.decorator';
|
import { CurrentUser, UserPayload } from '../decorators/current-user.decorator';
|
||||||
import { Roles } from '../decorators/roles.decorator';
|
import { Roles } from '../decorators/roles.decorator';
|
||||||
import { RequiresFeature } from '../decorators/requires-feature.decorator';
|
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import * as argon2 from 'argon2';
|
import * as argon2 from 'argon2';
|
||||||
import * as crypto from 'crypto';
|
import * as crypto from 'crypto';
|
||||||
@ -66,15 +64,14 @@ import { SubscriptionService } from '../services/subscription.service';
|
|||||||
*/
|
*/
|
||||||
@ApiTags('Users')
|
@ApiTags('Users')
|
||||||
@Controller('users')
|
@Controller('users')
|
||||||
@UseGuards(JwtAuthGuard, RolesGuard, FeatureFlagGuard)
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
@RequiresFeature('user_management')
|
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
export class UsersController {
|
export class UsersController {
|
||||||
private readonly logger = new Logger(UsersController.name);
|
private readonly logger = new Logger(UsersController.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(USER_REPOSITORY) private readonly userRepository: UserRepository,
|
@Inject(USER_REPOSITORY) private readonly userRepository: UserRepository,
|
||||||
private readonly subscriptionService: SubscriptionService
|
private readonly subscriptionService: SubscriptionService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -287,7 +284,7 @@ export class UsersController {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`Failed to reallocate license for user ${id}:`, error);
|
this.logger.error(`Failed to reallocate license for user ${id}:`, error);
|
||||||
throw new ForbiddenException(
|
throw new ForbiddenException(
|
||||||
'Cannot reactivate user: no licenses available. Please upgrade your subscription.'
|
'Cannot reactivate user: no licenses available. Please upgrade your subscription.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -1,24 +1,13 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
|
||||||
import { CsvBookingsController } from './controllers/csv-bookings.controller';
|
import { CsvBookingsController } from './controllers/csv-bookings.controller';
|
||||||
import { CsvBookingActionsController } from './controllers/csv-booking-actions.controller';
|
import { CsvBookingActionsController } from './controllers/csv-booking-actions.controller';
|
||||||
import { CsvBookingService } from './services/csv-booking.service';
|
import { CsvBookingService } from './services/csv-booking.service';
|
||||||
import { CsvBookingOrmEntity } from '../infrastructure/persistence/typeorm/entities/csv-booking.orm-entity';
|
import { CsvBookingOrmEntity } from '../infrastructure/persistence/typeorm/entities/csv-booking.orm-entity';
|
||||||
import { TypeOrmCsvBookingRepository } from '../infrastructure/persistence/typeorm/repositories/csv-booking.repository';
|
import { TypeOrmCsvBookingRepository } from '../infrastructure/persistence/typeorm/repositories/csv-booking.repository';
|
||||||
import { TypeOrmShipmentCounterRepository } from '../infrastructure/persistence/typeorm/repositories/shipment-counter.repository';
|
|
||||||
import { SHIPMENT_COUNTER_PORT } from '@domain/ports/out/shipment-counter.port';
|
|
||||||
import { ORGANIZATION_REPOSITORY } from '@domain/ports/out/organization.repository';
|
|
||||||
import { OrganizationOrmEntity } from '../infrastructure/persistence/typeorm/entities/organization.orm-entity';
|
|
||||||
import { TypeOrmOrganizationRepository } from '../infrastructure/persistence/typeorm/repositories/typeorm-organization.repository';
|
|
||||||
import { USER_REPOSITORY } from '@domain/ports/out/user.repository';
|
|
||||||
import { UserOrmEntity } from '../infrastructure/persistence/typeorm/entities/user.orm-entity';
|
|
||||||
import { TypeOrmUserRepository } from '../infrastructure/persistence/typeorm/repositories/typeorm-user.repository';
|
|
||||||
import { NotificationsModule } from './notifications/notifications.module';
|
import { NotificationsModule } from './notifications/notifications.module';
|
||||||
import { EmailModule } from '../infrastructure/email/email.module';
|
import { EmailModule } from '../infrastructure/email/email.module';
|
||||||
import { StorageModule } from '../infrastructure/storage/storage.module';
|
import { StorageModule } from '../infrastructure/storage/storage.module';
|
||||||
import { SubscriptionsModule } from './subscriptions/subscriptions.module';
|
|
||||||
import { StripeModule } from '../infrastructure/stripe/stripe.module';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CSV Bookings Module
|
* CSV Bookings Module
|
||||||
@ -27,31 +16,13 @@ import { StripeModule } from '../infrastructure/stripe/stripe.module';
|
|||||||
*/
|
*/
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([CsvBookingOrmEntity, OrganizationOrmEntity, UserOrmEntity]),
|
TypeOrmModule.forFeature([CsvBookingOrmEntity]),
|
||||||
ConfigModule,
|
|
||||||
NotificationsModule,
|
NotificationsModule,
|
||||||
EmailModule,
|
EmailModule,
|
||||||
StorageModule,
|
StorageModule,
|
||||||
SubscriptionsModule,
|
|
||||||
StripeModule,
|
|
||||||
],
|
],
|
||||||
controllers: [CsvBookingsController, CsvBookingActionsController],
|
controllers: [CsvBookingsController, CsvBookingActionsController],
|
||||||
providers: [
|
providers: [CsvBookingService, TypeOrmCsvBookingRepository],
|
||||||
CsvBookingService,
|
|
||||||
TypeOrmCsvBookingRepository,
|
|
||||||
{
|
|
||||||
provide: SHIPMENT_COUNTER_PORT,
|
|
||||||
useClass: TypeOrmShipmentCounterRepository,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: ORGANIZATION_REPOSITORY,
|
|
||||||
useClass: TypeOrmOrganizationRepository,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: USER_REPOSITORY,
|
|
||||||
useClass: TypeOrmUserRepository,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
exports: [CsvBookingService, TypeOrmCsvBookingRepository],
|
exports: [CsvBookingService, TypeOrmCsvBookingRepository],
|
||||||
})
|
})
|
||||||
export class CsvBookingsModule {}
|
export class CsvBookingsModule {}
|
||||||
|
|||||||
@ -7,12 +7,9 @@
|
|||||||
import { Controller, Get, UseGuards, Request } from '@nestjs/common';
|
import { Controller, Get, UseGuards, Request } from '@nestjs/common';
|
||||||
import { AnalyticsService } from '../services/analytics.service';
|
import { AnalyticsService } from '../services/analytics.service';
|
||||||
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
||||||
import { FeatureFlagGuard } from '../guards/feature-flag.guard';
|
|
||||||
import { RequiresFeature } from '../decorators/requires-feature.decorator';
|
|
||||||
|
|
||||||
@Controller('dashboard')
|
@Controller('dashboard')
|
||||||
@UseGuards(JwtAuthGuard, FeatureFlagGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@RequiresFeature('dashboard')
|
|
||||||
export class DashboardController {
|
export class DashboardController {
|
||||||
constructor(private readonly analyticsService: AnalyticsService) {}
|
constructor(private readonly analyticsService: AnalyticsService) {}
|
||||||
|
|
||||||
|
|||||||
@ -8,13 +8,11 @@ import { AnalyticsService } from '../services/analytics.service';
|
|||||||
import { BookingsModule } from '../bookings/bookings.module';
|
import { BookingsModule } from '../bookings/bookings.module';
|
||||||
import { RatesModule } from '../rates/rates.module';
|
import { RatesModule } from '../rates/rates.module';
|
||||||
import { CsvBookingsModule } from '../csv-bookings.module';
|
import { CsvBookingsModule } from '../csv-bookings.module';
|
||||||
import { SubscriptionsModule } from '../subscriptions/subscriptions.module';
|
|
||||||
import { FeatureFlagGuard } from '../guards/feature-flag.guard';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [BookingsModule, RatesModule, CsvBookingsModule, SubscriptionsModule],
|
imports: [BookingsModule, RatesModule, CsvBookingsModule],
|
||||||
controllers: [DashboardController],
|
controllers: [DashboardController],
|
||||||
providers: [AnalyticsService, FeatureFlagGuard],
|
providers: [AnalyticsService],
|
||||||
exports: [AnalyticsService],
|
exports: [AnalyticsService],
|
||||||
})
|
})
|
||||||
export class DashboardModule {}
|
export class DashboardModule {}
|
||||||
|
|||||||
@ -1,15 +0,0 @@
|
|||||||
import { SetMetadata } from '@nestjs/common';
|
|
||||||
import { PlanFeature } from '@domain/value-objects/plan-feature.vo';
|
|
||||||
|
|
||||||
export const REQUIRED_FEATURES_KEY = 'requiredFeatures';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decorator to require specific plan features for a route.
|
|
||||||
* Works with FeatureFlagGuard to enforce access control.
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* @RequiresFeature('dashboard')
|
|
||||||
* @RequiresFeature('csv_export', 'api_access')
|
|
||||||
*/
|
|
||||||
export const RequiresFeature = (...features: PlanFeature[]) =>
|
|
||||||
SetMetadata(REQUIRED_FEATURES_KEY, features);
|
|
||||||
@ -94,18 +94,6 @@ export class RegisterOrganizationDto {
|
|||||||
@Matches(/^[A-Z]{2}$/, { message: 'Country must be a 2-letter ISO code (e.g., NL, US, CN)' })
|
@Matches(/^[A-Z]{2}$/, { message: 'Country must be a 2-letter ISO code (e.g., NL, US, CN)' })
|
||||||
country: string;
|
country: string;
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
example: '123456789',
|
|
||||||
description: 'French SIREN number (9 digits, required)',
|
|
||||||
minLength: 9,
|
|
||||||
maxLength: 9,
|
|
||||||
})
|
|
||||||
@IsString()
|
|
||||||
@MinLength(9, { message: 'SIREN must be exactly 9 digits' })
|
|
||||||
@MaxLength(9, { message: 'SIREN must be exactly 9 digits' })
|
|
||||||
@Matches(/^[0-9]{9}$/, { message: 'SIREN must be 9 digits' })
|
|
||||||
siren: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
example: 'MAEU',
|
example: 'MAEU',
|
||||||
description: 'Standard Carrier Alpha Code (4 uppercase letters, required for carriers only)',
|
description: 'Standard Carrier Alpha Code (4 uppercase letters, required for carriers only)',
|
||||||
|
|||||||
@ -1,118 +1,112 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { IsNotEmpty, IsString } from 'class-validator';
|
import { IsNotEmpty, IsString } from 'class-validator';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DTO for verifying document access password
|
* DTO for verifying document access password
|
||||||
*/
|
*/
|
||||||
export class VerifyDocumentAccessDto {
|
export class VerifyDocumentAccessDto {
|
||||||
@ApiProperty({ description: 'Password for document access (booking number code)' })
|
@ApiProperty({ description: 'Password for document access (booking number code)' })
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
password: string;
|
password: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Response DTO for checking document access requirements
|
* Response DTO for checking document access requirements
|
||||||
*/
|
*/
|
||||||
export class DocumentAccessRequirementsDto {
|
export class DocumentAccessRequirementsDto {
|
||||||
@ApiProperty({ description: 'Whether password is required to access documents' })
|
@ApiProperty({ description: 'Whether password is required to access documents' })
|
||||||
requiresPassword: boolean;
|
requiresPassword: boolean;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Booking number (if available)' })
|
@ApiPropertyOptional({ description: 'Booking number (if available)' })
|
||||||
bookingNumber?: string;
|
bookingNumber?: string;
|
||||||
|
|
||||||
@ApiProperty({ description: 'Current booking status' })
|
@ApiProperty({ description: 'Current booking status' })
|
||||||
status: string;
|
status: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Booking Summary DTO for Carrier Documents Page
|
* Booking Summary DTO for Carrier Documents Page
|
||||||
*/
|
*/
|
||||||
export class BookingSummaryDto {
|
export class BookingSummaryDto {
|
||||||
@ApiProperty({ description: 'Booking unique ID' })
|
@ApiProperty({ description: 'Booking unique ID' })
|
||||||
id: string;
|
id: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Human-readable booking number' })
|
@ApiPropertyOptional({ description: 'Human-readable booking number' })
|
||||||
bookingNumber?: string;
|
bookingNumber?: string;
|
||||||
|
|
||||||
@ApiProperty({ description: 'Carrier/Company name' })
|
@ApiProperty({ description: 'Carrier/Company name' })
|
||||||
carrierName: string;
|
carrierName: string;
|
||||||
|
|
||||||
@ApiProperty({ description: 'Origin port code' })
|
@ApiProperty({ description: 'Origin port code' })
|
||||||
origin: string;
|
origin: string;
|
||||||
|
|
||||||
@ApiProperty({ description: 'Destination port code' })
|
@ApiProperty({ description: 'Destination port code' })
|
||||||
destination: string;
|
destination: string;
|
||||||
|
|
||||||
@ApiProperty({ description: 'Route description (origin -> destination)' })
|
@ApiProperty({ description: 'Route description (origin -> destination)' })
|
||||||
routeDescription: string;
|
routeDescription: string;
|
||||||
|
|
||||||
@ApiProperty({ description: 'Volume in CBM' })
|
@ApiProperty({ description: 'Volume in CBM' })
|
||||||
volumeCBM: number;
|
volumeCBM: number;
|
||||||
|
|
||||||
@ApiProperty({ description: 'Weight in KG' })
|
@ApiProperty({ description: 'Weight in KG' })
|
||||||
weightKG: number;
|
weightKG: number;
|
||||||
|
|
||||||
@ApiProperty({ description: 'Number of pallets' })
|
@ApiProperty({ description: 'Number of pallets' })
|
||||||
palletCount: number;
|
palletCount: number;
|
||||||
|
|
||||||
@ApiProperty({ description: 'Price in the primary currency' })
|
@ApiProperty({ description: 'Price in the primary currency' })
|
||||||
price: number;
|
price: number;
|
||||||
|
|
||||||
@ApiProperty({ description: 'Currency (USD or EUR)' })
|
@ApiProperty({ description: 'Currency (USD or EUR)' })
|
||||||
currency: string;
|
currency: string;
|
||||||
|
|
||||||
@ApiProperty({ description: 'Transit time in days' })
|
@ApiProperty({ description: 'Transit time in days' })
|
||||||
transitDays: number;
|
transitDays: number;
|
||||||
|
|
||||||
@ApiProperty({ description: 'Container type' })
|
@ApiProperty({ description: 'Container type' })
|
||||||
containerType: string;
|
containerType: string;
|
||||||
|
|
||||||
@ApiProperty({ description: 'When the booking was accepted' })
|
@ApiProperty({ description: 'When the booking was accepted' })
|
||||||
acceptedAt: Date;
|
acceptedAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Document with signed download URL for carrier access
|
* Document with signed download URL for carrier access
|
||||||
*/
|
*/
|
||||||
export class DocumentWithUrlDto {
|
export class DocumentWithUrlDto {
|
||||||
@ApiProperty({ description: 'Document unique ID' })
|
@ApiProperty({ description: 'Document unique ID' })
|
||||||
id: string;
|
id: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: 'Document type',
|
description: 'Document type',
|
||||||
enum: [
|
enum: ['BILL_OF_LADING', 'PACKING_LIST', 'COMMERCIAL_INVOICE', 'CERTIFICATE_OF_ORIGIN', 'OTHER'],
|
||||||
'BILL_OF_LADING',
|
})
|
||||||
'PACKING_LIST',
|
type: string;
|
||||||
'COMMERCIAL_INVOICE',
|
|
||||||
'CERTIFICATE_OF_ORIGIN',
|
@ApiProperty({ description: 'Original file name' })
|
||||||
'OTHER',
|
fileName: string;
|
||||||
],
|
|
||||||
})
|
@ApiProperty({ description: 'File MIME type' })
|
||||||
type: string;
|
mimeType: string;
|
||||||
|
|
||||||
@ApiProperty({ description: 'Original file name' })
|
@ApiProperty({ description: 'File size in bytes' })
|
||||||
fileName: string;
|
size: number;
|
||||||
|
|
||||||
@ApiProperty({ description: 'File MIME type' })
|
@ApiProperty({ description: 'Temporary signed download URL (valid for 1 hour)' })
|
||||||
mimeType: string;
|
downloadUrl: string;
|
||||||
|
}
|
||||||
@ApiProperty({ description: 'File size in bytes' })
|
|
||||||
size: number;
|
/**
|
||||||
|
* Carrier Documents Response DTO
|
||||||
@ApiProperty({ description: 'Temporary signed download URL (valid for 1 hour)' })
|
*
|
||||||
downloadUrl: string;
|
* Response for carrier document access page
|
||||||
}
|
*/
|
||||||
|
export class CarrierDocumentsResponseDto {
|
||||||
/**
|
@ApiProperty({ description: 'Booking summary information', type: BookingSummaryDto })
|
||||||
* Carrier Documents Response DTO
|
booking: BookingSummaryDto;
|
||||||
*
|
|
||||||
* Response for carrier document access page
|
@ApiProperty({ description: 'List of documents with download URLs', type: [DocumentWithUrlDto] })
|
||||||
*/
|
documents: DocumentWithUrlDto[];
|
||||||
export class CarrierDocumentsResponseDto {
|
}
|
||||||
@ApiProperty({ description: 'Booking summary information', type: BookingSummaryDto })
|
|
||||||
booking: BookingSummaryDto;
|
|
||||||
|
|
||||||
@ApiProperty({ description: 'List of documents with download URLs', type: [DocumentWithUrlDto] })
|
|
||||||
documents: DocumentWithUrlDto[];
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,139 +1,139 @@
|
|||||||
/**
|
/**
|
||||||
* Cookie Consent DTOs
|
* Cookie Consent DTOs
|
||||||
* GDPR compliant consent management
|
* GDPR compliant consent management
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { IsBoolean, IsOptional, IsString, IsEnum } from 'class-validator';
|
import { IsBoolean, IsOptional, IsString, IsEnum, IsDateString, IsIP } from 'class-validator';
|
||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Request DTO for recording/updating cookie consent
|
* Request DTO for recording/updating cookie consent
|
||||||
*/
|
*/
|
||||||
export class UpdateConsentDto {
|
export class UpdateConsentDto {
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: true,
|
example: true,
|
||||||
description: 'Essential cookies consent (always true, required for functionality)',
|
description: 'Essential cookies consent (always true, required for functionality)',
|
||||||
default: true,
|
default: true,
|
||||||
})
|
})
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
essential: boolean;
|
essential: boolean;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: false,
|
example: false,
|
||||||
description: 'Functional cookies consent (preferences, language, etc.)',
|
description: 'Functional cookies consent (preferences, language, etc.)',
|
||||||
default: false,
|
default: false,
|
||||||
})
|
})
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
functional: boolean;
|
functional: boolean;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: false,
|
example: false,
|
||||||
description: 'Analytics cookies consent (Google Analytics, Sentry, etc.)',
|
description: 'Analytics cookies consent (Google Analytics, Sentry, etc.)',
|
||||||
default: false,
|
default: false,
|
||||||
})
|
})
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
analytics: boolean;
|
analytics: boolean;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: false,
|
example: false,
|
||||||
description: 'Marketing cookies consent (ads, tracking, remarketing)',
|
description: 'Marketing cookies consent (ads, tracking, remarketing)',
|
||||||
default: false,
|
default: false,
|
||||||
})
|
})
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
marketing: boolean;
|
marketing: boolean;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
example: '192.168.1.1',
|
example: '192.168.1.1',
|
||||||
description: 'IP address at time of consent (for GDPR audit trail)',
|
description: 'IP address at time of consent (for GDPR audit trail)',
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
ipAddress?: string;
|
ipAddress?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
example: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
example: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||||
description: 'User agent at time of consent',
|
description: 'User agent at time of consent',
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
userAgent?: string;
|
userAgent?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Response DTO for consent status
|
* Response DTO for consent status
|
||||||
*/
|
*/
|
||||||
export class ConsentResponseDto {
|
export class ConsentResponseDto {
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: '550e8400-e29b-41d4-a716-446655440000',
|
example: '550e8400-e29b-41d4-a716-446655440000',
|
||||||
description: 'User ID',
|
description: 'User ID',
|
||||||
})
|
})
|
||||||
userId: string;
|
userId: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: true,
|
example: true,
|
||||||
description: 'Essential cookies consent (always true)',
|
description: 'Essential cookies consent (always true)',
|
||||||
})
|
})
|
||||||
essential: boolean;
|
essential: boolean;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: false,
|
example: false,
|
||||||
description: 'Functional cookies consent',
|
description: 'Functional cookies consent',
|
||||||
})
|
})
|
||||||
functional: boolean;
|
functional: boolean;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: false,
|
example: false,
|
||||||
description: 'Analytics cookies consent',
|
description: 'Analytics cookies consent',
|
||||||
})
|
})
|
||||||
analytics: boolean;
|
analytics: boolean;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: false,
|
example: false,
|
||||||
description: 'Marketing cookies consent',
|
description: 'Marketing cookies consent',
|
||||||
})
|
})
|
||||||
marketing: boolean;
|
marketing: boolean;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: '2025-01-27T10:30:00.000Z',
|
example: '2025-01-27T10:30:00.000Z',
|
||||||
description: 'Date when consent was recorded',
|
description: 'Date when consent was recorded',
|
||||||
})
|
})
|
||||||
consentDate: Date;
|
consentDate: Date;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: '2025-01-27T10:30:00.000Z',
|
example: '2025-01-27T10:30:00.000Z',
|
||||||
description: 'Last update timestamp',
|
description: 'Last update timestamp',
|
||||||
})
|
})
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Request DTO for withdrawing specific consent
|
* Request DTO for withdrawing specific consent
|
||||||
*/
|
*/
|
||||||
export class WithdrawConsentDto {
|
export class WithdrawConsentDto {
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: 'marketing',
|
example: 'marketing',
|
||||||
description: 'Type of consent to withdraw',
|
description: 'Type of consent to withdraw',
|
||||||
enum: ['functional', 'analytics', 'marketing'],
|
enum: ['functional', 'analytics', 'marketing'],
|
||||||
})
|
})
|
||||||
@IsEnum(['functional', 'analytics', 'marketing'], {
|
@IsEnum(['functional', 'analytics', 'marketing'], {
|
||||||
message: 'Consent type must be functional, analytics, or marketing',
|
message: 'Consent type must be functional, analytics, or marketing',
|
||||||
})
|
})
|
||||||
consentType: 'functional' | 'analytics' | 'marketing';
|
consentType: 'functional' | 'analytics' | 'marketing';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Success response DTO
|
* Success response DTO
|
||||||
*/
|
*/
|
||||||
export class ConsentSuccessDto {
|
export class ConsentSuccessDto {
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: true,
|
example: true,
|
||||||
description: 'Operation success status',
|
description: 'Operation success status',
|
||||||
})
|
})
|
||||||
success: boolean;
|
success: boolean;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: 'Consent preferences saved successfully',
|
example: 'Consent preferences saved successfully',
|
||||||
description: 'Response message',
|
description: 'Response message',
|
||||||
})
|
})
|
||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -294,8 +294,8 @@ export class CsvBookingResponseDto {
|
|||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: 'Booking status',
|
description: 'Booking status',
|
||||||
enum: ['PENDING_PAYMENT', 'PENDING', 'ACCEPTED', 'REJECTED', 'CANCELLED'],
|
enum: ['PENDING', 'ACCEPTED', 'REJECTED', 'CANCELLED'],
|
||||||
example: 'PENDING_PAYMENT',
|
example: 'PENDING',
|
||||||
})
|
})
|
||||||
status: string;
|
status: string;
|
||||||
|
|
||||||
@ -353,18 +353,6 @@ export class CsvBookingResponseDto {
|
|||||||
example: 1850.5,
|
example: 1850.5,
|
||||||
})
|
})
|
||||||
price: number;
|
price: number;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
description: 'Commission rate in percent',
|
|
||||||
example: 5,
|
|
||||||
})
|
|
||||||
commissionRate?: number;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
description: 'Commission amount in EUR',
|
|
||||||
example: 313.27,
|
|
||||||
})
|
|
||||||
commissionAmountEur?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -426,12 +414,6 @@ export class CsvBookingListResponseDto {
|
|||||||
* Statistics for user's or organization's bookings
|
* Statistics for user's or organization's bookings
|
||||||
*/
|
*/
|
||||||
export class CsvBookingStatsDto {
|
export class CsvBookingStatsDto {
|
||||||
@ApiProperty({
|
|
||||||
description: 'Number of bookings awaiting payment',
|
|
||||||
example: 1,
|
|
||||||
})
|
|
||||||
pendingPayment: number;
|
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: 'Number of pending bookings',
|
description: 'Number of pending bookings',
|
||||||
example: 5,
|
example: 5,
|
||||||
|
|||||||
@ -184,19 +184,6 @@ export class UpdateOrganizationDto {
|
|||||||
@Matches(/^[0-9]{9}$/, { message: 'SIREN must be 9 digits' })
|
@Matches(/^[0-9]{9}$/, { message: 'SIREN must be 9 digits' })
|
||||||
siren?: string;
|
siren?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
example: '12345678901234',
|
|
||||||
description: 'French SIRET number (14 digits)',
|
|
||||||
minLength: 14,
|
|
||||||
maxLength: 14,
|
|
||||||
})
|
|
||||||
@IsString()
|
|
||||||
@IsOptional()
|
|
||||||
@MinLength(14)
|
|
||||||
@MaxLength(14)
|
|
||||||
@Matches(/^[0-9]{14}$/, { message: 'SIRET must be 14 digits' })
|
|
||||||
siret?: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
example: 'FR123456789',
|
example: 'FR123456789',
|
||||||
description: 'EU EORI number',
|
description: 'EU EORI number',
|
||||||
@ -357,25 +344,6 @@ export class OrganizationResponseDto {
|
|||||||
})
|
})
|
||||||
documents: OrganizationDocumentDto[];
|
documents: OrganizationDocumentDto[];
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
example: '12345678901234',
|
|
||||||
description: 'French SIRET number (14 digits)',
|
|
||||||
})
|
|
||||||
siret?: string;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
example: false,
|
|
||||||
description: 'Whether the SIRET has been verified by an admin',
|
|
||||||
})
|
|
||||||
siretVerified: boolean;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
example: 'none',
|
|
||||||
description: 'Organization status badge',
|
|
||||||
enum: ['none', 'silver', 'gold', 'platinium'],
|
|
||||||
})
|
|
||||||
statusBadge?: string;
|
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: true,
|
example: true,
|
||||||
description: 'Active status',
|
description: 'Active status',
|
||||||
|
|||||||
@ -5,16 +5,25 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { IsString, IsEnum, IsUrl, IsOptional } from 'class-validator';
|
import {
|
||||||
|
IsString,
|
||||||
|
IsEnum,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsUrl,
|
||||||
|
IsOptional,
|
||||||
|
IsBoolean,
|
||||||
|
IsInt,
|
||||||
|
Min,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Subscription plan types
|
* Subscription plan types
|
||||||
*/
|
*/
|
||||||
export enum SubscriptionPlanDto {
|
export enum SubscriptionPlanDto {
|
||||||
BRONZE = 'BRONZE',
|
FREE = 'FREE',
|
||||||
SILVER = 'SILVER',
|
STARTER = 'STARTER',
|
||||||
GOLD = 'GOLD',
|
PRO = 'PRO',
|
||||||
PLATINIUM = 'PLATINIUM',
|
ENTERPRISE = 'ENTERPRISE',
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -44,7 +53,7 @@ export enum BillingIntervalDto {
|
|||||||
*/
|
*/
|
||||||
export class CreateCheckoutSessionDto {
|
export class CreateCheckoutSessionDto {
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: SubscriptionPlanDto.SILVER,
|
example: SubscriptionPlanDto.STARTER,
|
||||||
description: 'The subscription plan to purchase',
|
description: 'The subscription plan to purchase',
|
||||||
enum: SubscriptionPlanDto,
|
enum: SubscriptionPlanDto,
|
||||||
})
|
})
|
||||||
@ -188,14 +197,14 @@ export class LicenseResponseDto {
|
|||||||
*/
|
*/
|
||||||
export class PlanDetailsDto {
|
export class PlanDetailsDto {
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: SubscriptionPlanDto.SILVER,
|
example: SubscriptionPlanDto.STARTER,
|
||||||
description: 'Plan identifier',
|
description: 'Plan identifier',
|
||||||
enum: SubscriptionPlanDto,
|
enum: SubscriptionPlanDto,
|
||||||
})
|
})
|
||||||
plan: SubscriptionPlanDto;
|
plan: SubscriptionPlanDto;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: 'Silver',
|
example: 'Starter',
|
||||||
description: 'Plan display name',
|
description: 'Plan display name',
|
||||||
})
|
})
|
||||||
name: string;
|
name: string;
|
||||||
@ -207,51 +216,20 @@ export class PlanDetailsDto {
|
|||||||
maxLicenses: number;
|
maxLicenses: number;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: 249,
|
example: 49,
|
||||||
description: 'Monthly price in EUR',
|
description: 'Monthly price in EUR',
|
||||||
})
|
})
|
||||||
monthlyPriceEur: number;
|
monthlyPriceEur: number;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: 2739,
|
example: 470,
|
||||||
description: 'Yearly price in EUR (11 months)',
|
description: 'Yearly price in EUR',
|
||||||
})
|
})
|
||||||
yearlyPriceEur: number;
|
yearlyPriceEur: number;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: -1,
|
example: ['Up to 5 users', 'Advanced rate search', 'CSV imports'],
|
||||||
description: 'Maximum shipments per year (-1 for unlimited)',
|
description: 'List of features included in this plan',
|
||||||
})
|
|
||||||
maxShipmentsPerYear: number;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
example: 3,
|
|
||||||
description: 'Commission rate percentage on shipments',
|
|
||||||
})
|
|
||||||
commissionRatePercent: number;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
example: 'email',
|
|
||||||
description: 'Support level: none, email, direct, dedicated_kam',
|
|
||||||
})
|
|
||||||
supportLevel: string;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
example: 'silver',
|
|
||||||
description: 'Status badge: none, silver, gold, platinium',
|
|
||||||
})
|
|
||||||
statusBadge: string;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
example: ['dashboard', 'wiki', 'user_management', 'csv_export'],
|
|
||||||
description: 'List of plan feature flags',
|
|
||||||
type: [String],
|
|
||||||
})
|
|
||||||
planFeatures: string[];
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
example: ["Jusqu'à 5 utilisateurs", 'Expéditions illimitées', 'Import CSV'],
|
|
||||||
description: 'List of human-readable features included in this plan',
|
|
||||||
type: [String],
|
type: [String],
|
||||||
})
|
})
|
||||||
features: string[];
|
features: string[];
|
||||||
@ -274,7 +252,7 @@ export class SubscriptionResponseDto {
|
|||||||
organizationId: string;
|
organizationId: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: SubscriptionPlanDto.SILVER,
|
example: SubscriptionPlanDto.STARTER,
|
||||||
description: 'Current subscription plan',
|
description: 'Current subscription plan',
|
||||||
enum: SubscriptionPlanDto,
|
enum: SubscriptionPlanDto,
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,108 +0,0 @@
|
|||||||
import {
|
|
||||||
Injectable,
|
|
||||||
CanActivate,
|
|
||||||
ExecutionContext,
|
|
||||||
ForbiddenException,
|
|
||||||
Inject,
|
|
||||||
Logger,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { Reflector } from '@nestjs/core';
|
|
||||||
import { PlanFeature } from '@domain/value-objects/plan-feature.vo';
|
|
||||||
import {
|
|
||||||
SubscriptionRepository,
|
|
||||||
SUBSCRIPTION_REPOSITORY,
|
|
||||||
} from '@domain/ports/out/subscription.repository';
|
|
||||||
import { REQUIRED_FEATURES_KEY } from '../decorators/requires-feature.decorator';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Feature Flag Guard
|
|
||||||
*
|
|
||||||
* Checks if the user's subscription plan includes the required features.
|
|
||||||
* First tries to read plan from JWT payload (fast path), falls back to DB lookup.
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* @UseGuards(JwtAuthGuard, RolesGuard, FeatureFlagGuard)
|
|
||||||
* @RequiresFeature('dashboard')
|
|
||||||
*/
|
|
||||||
@Injectable()
|
|
||||||
export class FeatureFlagGuard implements CanActivate {
|
|
||||||
private readonly logger = new Logger(FeatureFlagGuard.name);
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly reflector: Reflector,
|
|
||||||
@Inject(SUBSCRIPTION_REPOSITORY)
|
|
||||||
private readonly subscriptionRepository: SubscriptionRepository
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
|
||||||
// Get required features from @RequiresFeature() decorator
|
|
||||||
const requiredFeatures = this.reflector.getAllAndOverride<PlanFeature[]>(
|
|
||||||
REQUIRED_FEATURES_KEY,
|
|
||||||
[context.getHandler(), context.getClass()]
|
|
||||||
);
|
|
||||||
|
|
||||||
// If no features are required, allow access
|
|
||||||
if (!requiredFeatures || requiredFeatures.length === 0) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const request = context.switchToHttp().getRequest();
|
|
||||||
const user = request.user;
|
|
||||||
|
|
||||||
if (!user || !user.organizationId) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ADMIN users have full access to all features — no plan check needed
|
|
||||||
if (user.role === 'ADMIN') {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fast path: check plan features from JWT payload
|
|
||||||
if (user.planFeatures && Array.isArray(user.planFeatures)) {
|
|
||||||
const hasAllFeatures = requiredFeatures.every(feature => user.planFeatures.includes(feature));
|
|
||||||
|
|
||||||
if (hasAllFeatures) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// JWT says no — but JWT might be stale after an upgrade.
|
|
||||||
// Fall through to DB check.
|
|
||||||
}
|
|
||||||
|
|
||||||
// Slow path: DB lookup for fresh subscription data
|
|
||||||
try {
|
|
||||||
const subscription = await this.subscriptionRepository.findByOrganizationId(
|
|
||||||
user.organizationId
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!subscription) {
|
|
||||||
// No subscription means Bronze (free) plan — no premium features
|
|
||||||
this.throwFeatureRequired(requiredFeatures);
|
|
||||||
}
|
|
||||||
|
|
||||||
const plan = subscription!.plan;
|
|
||||||
const missingFeatures = requiredFeatures.filter(feature => !plan.hasFeature(feature));
|
|
||||||
|
|
||||||
if (missingFeatures.length > 0) {
|
|
||||||
this.throwFeatureRequired(requiredFeatures);
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof ForbiddenException) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
this.logger.error(`Failed to check subscription features: ${error}`);
|
|
||||||
// On DB error, deny access to premium features rather than 500
|
|
||||||
this.throwFeatureRequired(requiredFeatures);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private throwFeatureRequired(features: PlanFeature[]): never {
|
|
||||||
const featureNames = features.join(', ');
|
|
||||||
throw new ForbiddenException(
|
|
||||||
`Cette fonctionnalité nécessite un plan supérieur. Fonctionnalités requises : ${featureNames}. Passez à un plan Silver ou supérieur.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -31,9 +31,6 @@ export class OrganizationMapper {
|
|||||||
address: this.mapAddressToDto(organization.address),
|
address: this.mapAddressToDto(organization.address),
|
||||||
logoUrl: organization.logoUrl,
|
logoUrl: organization.logoUrl,
|
||||||
documents: organization.documents.map(doc => this.mapDocumentToDto(doc)),
|
documents: organization.documents.map(doc => this.mapDocumentToDto(doc)),
|
||||||
siret: organization.siret,
|
|
||||||
siretVerified: organization.siretVerified,
|
|
||||||
statusBadge: organization.statusBadge,
|
|
||||||
isActive: organization.isActive,
|
isActive: organization.isActive,
|
||||||
createdAt: organization.createdAt,
|
createdAt: organization.createdAt,
|
||||||
updatedAt: organization.updatedAt,
|
updatedAt: organization.updatedAt,
|
||||||
|
|||||||
@ -16,9 +16,7 @@ import {
|
|||||||
NOTIFICATION_REPOSITORY,
|
NOTIFICATION_REPOSITORY,
|
||||||
} from '@domain/ports/out/notification.repository';
|
} from '@domain/ports/out/notification.repository';
|
||||||
import { EmailPort, EMAIL_PORT } from '@domain/ports/out/email.port';
|
import { EmailPort, EMAIL_PORT } from '@domain/ports/out/email.port';
|
||||||
import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.repository';
|
|
||||||
import { StoragePort, STORAGE_PORT } from '@domain/ports/out/storage.port';
|
import { StoragePort, STORAGE_PORT } from '@domain/ports/out/storage.port';
|
||||||
import { StripePort, STRIPE_PORT } from '@domain/ports/out/stripe.port';
|
|
||||||
import {
|
import {
|
||||||
Notification,
|
Notification,
|
||||||
NotificationType,
|
NotificationType,
|
||||||
@ -32,7 +30,6 @@ import {
|
|||||||
CsvBookingStatsDto,
|
CsvBookingStatsDto,
|
||||||
} from '../dto/csv-booking.dto';
|
} from '../dto/csv-booking.dto';
|
||||||
import { CarrierDocumentsResponseDto } from '../dto/carrier-documents.dto';
|
import { CarrierDocumentsResponseDto } from '../dto/carrier-documents.dto';
|
||||||
import { SubscriptionService } from './subscription.service';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CSV Booking Document (simple class for domain)
|
* CSV Booking Document (simple class for domain)
|
||||||
@ -65,12 +62,7 @@ export class CsvBookingService {
|
|||||||
@Inject(EMAIL_PORT)
|
@Inject(EMAIL_PORT)
|
||||||
private readonly emailAdapter: EmailPort,
|
private readonly emailAdapter: EmailPort,
|
||||||
@Inject(STORAGE_PORT)
|
@Inject(STORAGE_PORT)
|
||||||
private readonly storageAdapter: StoragePort,
|
private readonly storageAdapter: StoragePort
|
||||||
@Inject(STRIPE_PORT)
|
|
||||||
private readonly stripeAdapter: StripePort,
|
|
||||||
private readonly subscriptionService: SubscriptionService,
|
|
||||||
@Inject(USER_REPOSITORY)
|
|
||||||
private readonly userRepository: UserRepository
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -122,18 +114,7 @@ export class CsvBookingService {
|
|||||||
// Upload documents to S3
|
// Upload documents to S3
|
||||||
const documents = await this.uploadDocuments(files, bookingId);
|
const documents = await this.uploadDocuments(files, bookingId);
|
||||||
|
|
||||||
// Calculate commission based on organization's subscription plan
|
// Create domain entity
|
||||||
let commissionRate = 5; // default Bronze
|
|
||||||
let commissionAmountEur = 0;
|
|
||||||
try {
|
|
||||||
const subscription = await this.subscriptionService.getOrCreateSubscription(organizationId);
|
|
||||||
commissionRate = subscription.plan.commissionRatePercent;
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`Failed to get subscription for commission: ${error?.message}`);
|
|
||||||
}
|
|
||||||
commissionAmountEur = Math.round(dto.priceEUR * commissionRate) / 100;
|
|
||||||
|
|
||||||
// Create domain entity in PENDING_PAYMENT status (no email sent yet)
|
|
||||||
const booking = new CsvBooking(
|
const booking = new CsvBooking(
|
||||||
bookingId,
|
bookingId,
|
||||||
userId,
|
userId,
|
||||||
@ -150,16 +131,12 @@ export class CsvBookingService {
|
|||||||
dto.primaryCurrency,
|
dto.primaryCurrency,
|
||||||
dto.transitDays,
|
dto.transitDays,
|
||||||
dto.containerType,
|
dto.containerType,
|
||||||
CsvBookingStatus.PENDING_PAYMENT,
|
CsvBookingStatus.PENDING,
|
||||||
documents,
|
documents,
|
||||||
confirmationToken,
|
confirmationToken,
|
||||||
new Date(),
|
new Date(),
|
||||||
undefined,
|
undefined,
|
||||||
dto.notes,
|
dto.notes
|
||||||
undefined,
|
|
||||||
bookingNumber,
|
|
||||||
commissionRate,
|
|
||||||
commissionAmountEur
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Save to database
|
// Save to database
|
||||||
@ -175,354 +152,58 @@ export class CsvBookingService {
|
|||||||
await this.csvBookingRepository['repository'].save(ormBooking);
|
await this.csvBookingRepository['repository'].save(ormBooking);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(`CSV booking created with ID: ${bookingId}, number: ${bookingNumber}`);
|
||||||
`CSV booking created with ID: ${bookingId}, number: ${bookingNumber}, status: PENDING_PAYMENT, commission: ${commissionRate}% = ${commissionAmountEur}€`
|
|
||||||
);
|
|
||||||
|
|
||||||
// NO email sent to carrier yet - will be sent after commission payment
|
// Send email to carrier and WAIT for confirmation
|
||||||
// NO notification yet - will be created after payment confirmation
|
// The button waits for the email to be sent before responding
|
||||||
|
|
||||||
return this.toResponseDto(savedBooking);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a Stripe Checkout session for commission payment
|
|
||||||
*/
|
|
||||||
async createCommissionPayment(
|
|
||||||
bookingId: string,
|
|
||||||
userId: string,
|
|
||||||
userEmail: string,
|
|
||||||
frontendUrl: string
|
|
||||||
): Promise<{ sessionUrl: string; sessionId: string; commissionAmountEur: number }> {
|
|
||||||
const booking = await this.csvBookingRepository.findById(bookingId);
|
|
||||||
|
|
||||||
if (!booking) {
|
|
||||||
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (booking.userId !== userId) {
|
|
||||||
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (booking.status !== CsvBookingStatus.PENDING_PAYMENT) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
`Booking is not awaiting payment. Current status: ${booking.status}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const commissionAmountEur = booking.commissionAmountEur || 0;
|
|
||||||
if (commissionAmountEur <= 0) {
|
|
||||||
throw new BadRequestException('Commission amount is invalid');
|
|
||||||
}
|
|
||||||
|
|
||||||
const amountCents = Math.round(commissionAmountEur * 100);
|
|
||||||
|
|
||||||
const result = await this.stripeAdapter.createCommissionCheckout({
|
|
||||||
bookingId: booking.id,
|
|
||||||
amountCents,
|
|
||||||
currency: 'eur',
|
|
||||||
customerEmail: userEmail,
|
|
||||||
organizationId: booking.organizationId,
|
|
||||||
bookingDescription: `Commission booking ${booking.bookingNumber || booking.id} - ${booking.origin.getValue()} → ${booking.destination.getValue()}`,
|
|
||||||
successUrl: `${frontendUrl}/dashboard/booking/${booking.id}/payment-success?session_id={CHECKOUT_SESSION_ID}`,
|
|
||||||
cancelUrl: `${frontendUrl}/dashboard/booking/${booking.id}/pay`,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.logger.log(
|
|
||||||
`Created Stripe commission checkout for booking ${bookingId}: ${amountCents} cents EUR`
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
sessionUrl: result.sessionUrl,
|
|
||||||
sessionId: result.sessionId,
|
|
||||||
commissionAmountEur,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Confirm commission payment and activate booking
|
|
||||||
* Called after Stripe redirect with session_id
|
|
||||||
*/
|
|
||||||
async confirmCommissionPayment(
|
|
||||||
bookingId: string,
|
|
||||||
sessionId: string,
|
|
||||||
userId: string
|
|
||||||
): Promise<CsvBookingResponseDto> {
|
|
||||||
const booking = await this.csvBookingRepository.findById(bookingId);
|
|
||||||
|
|
||||||
if (!booking) {
|
|
||||||
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (booking.userId !== userId) {
|
|
||||||
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (booking.status !== CsvBookingStatus.PENDING_PAYMENT) {
|
|
||||||
// Already confirmed - return current state
|
|
||||||
if (booking.status === CsvBookingStatus.PENDING) {
|
|
||||||
return this.toResponseDto(booking);
|
|
||||||
}
|
|
||||||
throw new BadRequestException(
|
|
||||||
`Booking is not awaiting payment. Current status: ${booking.status}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify payment with Stripe
|
|
||||||
const session = await this.stripeAdapter.getCheckoutSession(sessionId);
|
|
||||||
if (!session || session.status !== 'complete') {
|
|
||||||
throw new BadRequestException('Payment has not been completed');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify the session is for this booking
|
|
||||||
if (session.metadata?.bookingId !== bookingId) {
|
|
||||||
throw new BadRequestException('Payment session does not match this booking');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Transition to PENDING
|
|
||||||
booking.markPaymentCompleted();
|
|
||||||
booking.stripePaymentIntentId = sessionId;
|
|
||||||
|
|
||||||
// Save updated booking
|
|
||||||
const updatedBooking = await this.csvBookingRepository.update(booking);
|
|
||||||
this.logger.log(`Booking ${bookingId} payment confirmed, status now PENDING`);
|
|
||||||
|
|
||||||
// Get ORM entity for booking number
|
|
||||||
const ormBooking = await this.csvBookingRepository['repository'].findOne({
|
|
||||||
where: { id: bookingId },
|
|
||||||
});
|
|
||||||
const bookingNumber = ormBooking?.bookingNumber;
|
|
||||||
const documentPassword = bookingNumber
|
|
||||||
? this.extractPasswordFromBookingNumber(bookingNumber)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
// NOW send email to carrier
|
|
||||||
try {
|
try {
|
||||||
await this.emailAdapter.sendCsvBookingRequest(booking.carrierEmail, {
|
await this.emailAdapter.sendCsvBookingRequest(dto.carrierEmail, {
|
||||||
bookingId: booking.id,
|
bookingId,
|
||||||
bookingNumber: bookingNumber || '',
|
bookingNumber,
|
||||||
documentPassword: documentPassword || '',
|
documentPassword,
|
||||||
origin: booking.origin.getValue(),
|
origin: dto.origin,
|
||||||
destination: booking.destination.getValue(),
|
destination: dto.destination,
|
||||||
volumeCBM: booking.volumeCBM,
|
volumeCBM: dto.volumeCBM,
|
||||||
weightKG: booking.weightKG,
|
weightKG: dto.weightKG,
|
||||||
palletCount: booking.palletCount,
|
palletCount: dto.palletCount,
|
||||||
priceUSD: booking.priceUSD,
|
priceUSD: dto.priceUSD,
|
||||||
priceEUR: booking.priceEUR,
|
priceEUR: dto.priceEUR,
|
||||||
primaryCurrency: booking.primaryCurrency,
|
primaryCurrency: dto.primaryCurrency,
|
||||||
transitDays: booking.transitDays,
|
transitDays: dto.transitDays,
|
||||||
containerType: booking.containerType,
|
containerType: dto.containerType,
|
||||||
documents: booking.documents.map(doc => ({
|
documents: documents.map(doc => ({
|
||||||
type: doc.type,
|
type: doc.type,
|
||||||
fileName: doc.fileName,
|
fileName: doc.fileName,
|
||||||
})),
|
})),
|
||||||
confirmationToken: booking.confirmationToken,
|
confirmationToken,
|
||||||
notes: booking.notes,
|
notes: dto.notes,
|
||||||
});
|
});
|
||||||
this.logger.log(`Email sent to carrier: ${booking.carrierEmail}`);
|
this.logger.log(`Email sent to carrier: ${dto.carrierEmail}`);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.error(`Failed to send email to carrier: ${error?.message}`, error?.stack);
|
this.logger.error(`Failed to send email to carrier: ${error?.message}`, error?.stack);
|
||||||
|
// Continue even if email fails - booking is already saved
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create notification for user
|
// Create notification for user
|
||||||
try {
|
try {
|
||||||
const notification = Notification.create({
|
const notification = Notification.create({
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
userId: booking.userId,
|
userId,
|
||||||
organizationId: booking.organizationId,
|
organizationId,
|
||||||
type: NotificationType.CSV_BOOKING_REQUEST_SENT,
|
type: NotificationType.CSV_BOOKING_REQUEST_SENT,
|
||||||
priority: NotificationPriority.MEDIUM,
|
priority: NotificationPriority.MEDIUM,
|
||||||
title: 'Booking Request Sent',
|
title: 'Booking Request Sent',
|
||||||
message: `Your booking request to ${booking.carrierName} for ${booking.getRouteDescription()} has been sent successfully after payment.`,
|
message: `Your booking request to ${dto.carrierName} for ${dto.origin} → ${dto.destination} has been sent successfully.`,
|
||||||
metadata: { bookingId: booking.id, carrierName: booking.carrierName },
|
metadata: { bookingId, carrierName: dto.carrierName },
|
||||||
});
|
});
|
||||||
await this.notificationRepository.save(notification);
|
await this.notificationRepository.save(notification);
|
||||||
|
this.logger.log(`Notification created for user ${userId}`);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.error(`Failed to create notification: ${error?.message}`, error?.stack);
|
this.logger.error(`Failed to create notification: ${error?.message}`, error?.stack);
|
||||||
|
// Continue even if notification fails
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.toResponseDto(updatedBooking);
|
return this.toResponseDto(savedBooking);
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Declare bank transfer — user confirms they have sent the wire transfer
|
|
||||||
* Transitions booking from PENDING_PAYMENT → PENDING_BANK_TRANSFER
|
|
||||||
* Sends an email notification to all ADMIN users
|
|
||||||
*/
|
|
||||||
async declareBankTransfer(bookingId: string, userId: string): Promise<CsvBookingResponseDto> {
|
|
||||||
const booking = await this.csvBookingRepository.findById(bookingId);
|
|
||||||
|
|
||||||
if (!booking) {
|
|
||||||
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (booking.userId !== userId) {
|
|
||||||
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (booking.status !== CsvBookingStatus.PENDING_PAYMENT) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
`Booking is not awaiting payment. Current status: ${booking.status}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get booking number before update
|
|
||||||
const ormBooking = await this.csvBookingRepository['repository'].findOne({
|
|
||||||
where: { id: bookingId },
|
|
||||||
});
|
|
||||||
const bookingNumber = ormBooking?.bookingNumber || bookingId.slice(0, 8).toUpperCase();
|
|
||||||
|
|
||||||
booking.markBankTransferDeclared();
|
|
||||||
const updatedBooking = await this.csvBookingRepository.update(booking);
|
|
||||||
this.logger.log(`Booking ${bookingId} bank transfer declared, status now PENDING_BANK_TRANSFER`);
|
|
||||||
|
|
||||||
// Send email to all ADMIN users
|
|
||||||
try {
|
|
||||||
const allUsers = await this.userRepository.findAll();
|
|
||||||
const adminEmails = allUsers
|
|
||||||
.filter(u => u.role === 'ADMIN' && u.isActive)
|
|
||||||
.map(u => u.email);
|
|
||||||
|
|
||||||
if (adminEmails.length > 0) {
|
|
||||||
const commissionAmount = booking.commissionAmountEur
|
|
||||||
? new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(booking.commissionAmountEur)
|
|
||||||
: 'N/A';
|
|
||||||
|
|
||||||
await this.emailAdapter.send({
|
|
||||||
to: adminEmails,
|
|
||||||
subject: `[XPEDITIS] Virement à valider — ${bookingNumber}`,
|
|
||||||
html: `
|
|
||||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
|
||||||
<h2 style="color: #10183A;">Nouveau virement à valider</h2>
|
|
||||||
<p>Un client a déclaré avoir effectué un virement bancaire pour le booking suivant :</p>
|
|
||||||
<table style="width: 100%; border-collapse: collapse; margin: 16px 0;">
|
|
||||||
<tr style="background: #f5f5f5;">
|
|
||||||
<td style="padding: 8px 12px; font-weight: bold;">Numéro de booking</td>
|
|
||||||
<td style="padding: 8px 12px;">${bookingNumber}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 8px 12px; font-weight: bold;">Transporteur</td>
|
|
||||||
<td style="padding: 8px 12px;">${booking.carrierName}</td>
|
|
||||||
</tr>
|
|
||||||
<tr style="background: #f5f5f5;">
|
|
||||||
<td style="padding: 8px 12px; font-weight: bold;">Trajet</td>
|
|
||||||
<td style="padding: 8px 12px;">${booking.getRouteDescription()}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 8px 12px; font-weight: bold;">Montant commission</td>
|
|
||||||
<td style="padding: 8px 12px; color: #10183A; font-weight: bold;">${commissionAmount}</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<p>Rendez-vous dans la <strong>console d'administration</strong> pour valider ce virement et activer le booking.</p>
|
|
||||||
<a href="${process.env.APP_URL || 'http://localhost:3000'}/dashboard/admin/bookings"
|
|
||||||
style="display: inline-block; background: #10183A; color: white; padding: 12px 24px; border-radius: 6px; text-decoration: none; margin-top: 8px;">
|
|
||||||
Voir les bookings en attente
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
});
|
|
||||||
this.logger.log(`Admin notification email sent to: ${adminEmails.join(', ')}`);
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`Failed to send admin notification email: ${error?.message}`, error?.stack);
|
|
||||||
}
|
|
||||||
|
|
||||||
// In-app notification for the user
|
|
||||||
try {
|
|
||||||
const notification = Notification.create({
|
|
||||||
id: uuidv4(),
|
|
||||||
userId: booking.userId,
|
|
||||||
organizationId: booking.organizationId,
|
|
||||||
type: NotificationType.BOOKING_UPDATED,
|
|
||||||
priority: NotificationPriority.MEDIUM,
|
|
||||||
title: 'Virement déclaré',
|
|
||||||
message: `Votre virement pour le booking ${bookingNumber} a été enregistré. Un administrateur va vérifier la réception et activer votre booking.`,
|
|
||||||
metadata: { bookingId: booking.id },
|
|
||||||
});
|
|
||||||
await this.notificationRepository.save(notification);
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`Failed to create user notification: ${error?.message}`, error?.stack);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.toResponseDto(updatedBooking);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Admin validates bank transfer — confirms receipt and activates booking
|
|
||||||
* Transitions booking from PENDING_BANK_TRANSFER → PENDING then sends email to carrier
|
|
||||||
*/
|
|
||||||
async validateBankTransfer(bookingId: string): Promise<CsvBookingResponseDto> {
|
|
||||||
const booking = await this.csvBookingRepository.findById(bookingId);
|
|
||||||
|
|
||||||
if (!booking) {
|
|
||||||
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (booking.status !== CsvBookingStatus.PENDING_BANK_TRANSFER) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
`Booking is not awaiting bank transfer validation. Current status: ${booking.status}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
booking.markBankTransferValidated();
|
|
||||||
const updatedBooking = await this.csvBookingRepository.update(booking);
|
|
||||||
this.logger.log(`Booking ${bookingId} bank transfer validated by admin, status now PENDING`);
|
|
||||||
|
|
||||||
// Get booking number for email
|
|
||||||
const ormBooking = await this.csvBookingRepository['repository'].findOne({
|
|
||||||
where: { id: bookingId },
|
|
||||||
});
|
|
||||||
const bookingNumber = ormBooking?.bookingNumber;
|
|
||||||
const documentPassword = bookingNumber
|
|
||||||
? this.extractPasswordFromBookingNumber(bookingNumber)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
// Send email to carrier
|
|
||||||
try {
|
|
||||||
await this.emailAdapter.sendCsvBookingRequest(booking.carrierEmail, {
|
|
||||||
bookingId: booking.id,
|
|
||||||
bookingNumber: bookingNumber || '',
|
|
||||||
documentPassword: documentPassword || '',
|
|
||||||
origin: booking.origin.getValue(),
|
|
||||||
destination: booking.destination.getValue(),
|
|
||||||
volumeCBM: booking.volumeCBM,
|
|
||||||
weightKG: booking.weightKG,
|
|
||||||
palletCount: booking.palletCount,
|
|
||||||
priceUSD: booking.priceUSD,
|
|
||||||
priceEUR: booking.priceEUR,
|
|
||||||
primaryCurrency: booking.primaryCurrency,
|
|
||||||
transitDays: booking.transitDays,
|
|
||||||
containerType: booking.containerType,
|
|
||||||
documents: booking.documents.map(doc => ({
|
|
||||||
type: doc.type,
|
|
||||||
fileName: doc.fileName,
|
|
||||||
})),
|
|
||||||
confirmationToken: booking.confirmationToken,
|
|
||||||
notes: booking.notes,
|
|
||||||
});
|
|
||||||
this.logger.log(`Email sent to carrier after bank transfer validation: ${booking.carrierEmail}`);
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`Failed to send email to carrier: ${error?.message}`, error?.stack);
|
|
||||||
}
|
|
||||||
|
|
||||||
// In-app notification for the user
|
|
||||||
try {
|
|
||||||
const notification = Notification.create({
|
|
||||||
id: uuidv4(),
|
|
||||||
userId: booking.userId,
|
|
||||||
organizationId: booking.organizationId,
|
|
||||||
type: NotificationType.BOOKING_CONFIRMED,
|
|
||||||
priority: NotificationPriority.HIGH,
|
|
||||||
title: 'Virement validé — Booking activé',
|
|
||||||
message: `Votre virement pour le booking ${bookingNumber || booking.id.slice(0, 8)} a été confirmé. Votre demande auprès de ${booking.carrierName} a été transmise au transporteur.`,
|
|
||||||
metadata: { bookingId: booking.id },
|
|
||||||
});
|
|
||||||
await this.notificationRepository.save(notification);
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`Failed to create user notification: ${error?.message}`, error?.stack);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.toResponseDto(updatedBooking);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -713,21 +394,6 @@ export class CsvBookingService {
|
|||||||
// Accept the booking (domain logic validates status)
|
// Accept the booking (domain logic validates status)
|
||||||
booking.accept();
|
booking.accept();
|
||||||
|
|
||||||
// Apply commission based on organization's subscription plan
|
|
||||||
try {
|
|
||||||
const subscription = await this.subscriptionService.getOrCreateSubscription(
|
|
||||||
booking.organizationId
|
|
||||||
);
|
|
||||||
const commissionRate = subscription.plan.commissionRatePercent;
|
|
||||||
const baseAmountEur = booking.priceEUR;
|
|
||||||
booking.applyCommission(commissionRate, baseAmountEur);
|
|
||||||
this.logger.log(
|
|
||||||
`Commission applied: ${commissionRate}% on ${baseAmountEur}€ = ${booking.commissionAmountEur}€`
|
|
||||||
);
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`Failed to apply commission: ${error?.message}`, error?.stack);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save updated booking
|
// Save updated booking
|
||||||
const updatedBooking = await this.csvBookingRepository.update(booking);
|
const updatedBooking = await this.csvBookingRepository.update(booking);
|
||||||
this.logger.log(`Booking ${booking.id} accepted`);
|
this.logger.log(`Booking ${booking.id} accepted`);
|
||||||
@ -902,7 +568,6 @@ export class CsvBookingService {
|
|||||||
const stats = await this.csvBookingRepository.countByStatusForUser(userId);
|
const stats = await this.csvBookingRepository.countByStatusForUser(userId);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
pendingPayment: stats[CsvBookingStatus.PENDING_PAYMENT] || 0,
|
|
||||||
pending: stats[CsvBookingStatus.PENDING] || 0,
|
pending: stats[CsvBookingStatus.PENDING] || 0,
|
||||||
accepted: stats[CsvBookingStatus.ACCEPTED] || 0,
|
accepted: stats[CsvBookingStatus.ACCEPTED] || 0,
|
||||||
rejected: stats[CsvBookingStatus.REJECTED] || 0,
|
rejected: stats[CsvBookingStatus.REJECTED] || 0,
|
||||||
@ -918,7 +583,6 @@ export class CsvBookingService {
|
|||||||
const stats = await this.csvBookingRepository.countByStatusForOrganization(organizationId);
|
const stats = await this.csvBookingRepository.countByStatusForOrganization(organizationId);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
pendingPayment: stats[CsvBookingStatus.PENDING_PAYMENT] || 0,
|
|
||||||
pending: stats[CsvBookingStatus.PENDING] || 0,
|
pending: stats[CsvBookingStatus.PENDING] || 0,
|
||||||
accepted: stats[CsvBookingStatus.ACCEPTED] || 0,
|
accepted: stats[CsvBookingStatus.ACCEPTED] || 0,
|
||||||
rejected: stats[CsvBookingStatus.REJECTED] || 0,
|
rejected: stats[CsvBookingStatus.REJECTED] || 0,
|
||||||
@ -1014,15 +678,9 @@ export class CsvBookingService {
|
|||||||
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
|
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allow adding documents to PENDING_PAYMENT, PENDING, or ACCEPTED bookings
|
// Allow adding documents to PENDING or ACCEPTED bookings
|
||||||
if (
|
if (booking.status !== CsvBookingStatus.PENDING && booking.status !== CsvBookingStatus.ACCEPTED) {
|
||||||
booking.status !== CsvBookingStatus.PENDING_PAYMENT &&
|
throw new BadRequestException('Cannot add documents to a booking that is rejected or cancelled');
|
||||||
booking.status !== CsvBookingStatus.PENDING &&
|
|
||||||
booking.status !== CsvBookingStatus.ACCEPTED
|
|
||||||
) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
'Cannot add documents to a booking that is rejected or cancelled'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upload new documents
|
// Upload new documents
|
||||||
@ -1065,10 +723,7 @@ export class CsvBookingService {
|
|||||||
});
|
});
|
||||||
this.logger.log(`New documents notification sent to carrier: ${booking.carrierEmail}`);
|
this.logger.log(`New documents notification sent to carrier: ${booking.carrierEmail}`);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.error(
|
this.logger.error(`Failed to send new documents notification: ${error?.message}`, error?.stack);
|
||||||
`Failed to send new documents notification: ${error?.message}`,
|
|
||||||
error?.stack
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1100,11 +755,8 @@ export class CsvBookingService {
|
|||||||
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
|
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify booking is still pending or awaiting payment
|
// Verify booking is still pending
|
||||||
if (
|
if (booking.status !== CsvBookingStatus.PENDING) {
|
||||||
booking.status !== CsvBookingStatus.PENDING_PAYMENT &&
|
|
||||||
booking.status !== CsvBookingStatus.PENDING
|
|
||||||
) {
|
|
||||||
throw new BadRequestException('Cannot delete documents from a booking that is not pending');
|
throw new BadRequestException('Cannot delete documents from a booking that is not pending');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1219,9 +871,7 @@ export class CsvBookingService {
|
|||||||
await this.csvBookingRepository['repository'].save(ormBooking);
|
await this.csvBookingRepository['repository'].save(ormBooking);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(`Replaced document ${documentId} with ${newDocument.id} in booking ${bookingId}`);
|
||||||
`Replaced document ${documentId} with ${newDocument.id} in booking ${bookingId}`
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
@ -1297,8 +947,6 @@ export class CsvBookingService {
|
|||||||
routeDescription: booking.getRouteDescription(),
|
routeDescription: booking.getRouteDescription(),
|
||||||
isExpired: booking.isExpired(),
|
isExpired: booking.isExpired(),
|
||||||
price: booking.getPriceInCurrency(primaryCurrency),
|
price: booking.getPriceInCurrency(primaryCurrency),
|
||||||
commissionRate: booking.commissionRate,
|
|
||||||
commissionAmountEur: booking.commissionAmountEur,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -120,7 +120,10 @@ export class GDPRService {
|
|||||||
/**
|
/**
|
||||||
* Record or update consent (GDPR Article 7 - Conditions for consent)
|
* Record or update consent (GDPR Article 7 - Conditions for consent)
|
||||||
*/
|
*/
|
||||||
async recordConsent(userId: string, consentData: UpdateConsentDto): Promise<ConsentResponseDto> {
|
async recordConsent(
|
||||||
|
userId: string,
|
||||||
|
consentData: UpdateConsentDto
|
||||||
|
): Promise<ConsentResponseDto> {
|
||||||
this.logger.log(`Recording consent for user ${userId}`);
|
this.logger.log(`Recording consent for user ${userId}`);
|
||||||
|
|
||||||
// Verify user exists
|
// Verify user exists
|
||||||
|
|||||||
@ -38,7 +38,7 @@ export class InvitationService {
|
|||||||
@Inject(EMAIL_PORT)
|
@Inject(EMAIL_PORT)
|
||||||
private readonly emailService: EmailPort,
|
private readonly emailService: EmailPort,
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
private readonly subscriptionService: SubscriptionService
|
private readonly subscriptionService: SubscriptionService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -50,8 +50,7 @@ export class InvitationService {
|
|||||||
lastName: string,
|
lastName: string,
|
||||||
role: UserRole,
|
role: UserRole,
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
invitedById: string,
|
invitedById: string
|
||||||
inviterRole?: string
|
|
||||||
): Promise<InvitationToken> {
|
): Promise<InvitationToken> {
|
||||||
this.logger.log(`Creating invitation for ${email} in organization ${organizationId}`);
|
this.logger.log(`Creating invitation for ${email} in organization ${organizationId}`);
|
||||||
|
|
||||||
@ -70,14 +69,14 @@ export class InvitationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if licenses are available for this organization
|
// Check if licenses are available for this organization
|
||||||
const canInviteResult = await this.subscriptionService.canInviteUser(organizationId, inviterRole);
|
const canInviteResult = await this.subscriptionService.canInviteUser(organizationId);
|
||||||
if (!canInviteResult.canInvite) {
|
if (!canInviteResult.canInvite) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`License limit reached for organization ${organizationId}: ${canInviteResult.usedLicenses}/${canInviteResult.maxLicenses}`
|
`License limit reached for organization ${organizationId}: ${canInviteResult.usedLicenses}/${canInviteResult.maxLicenses}`,
|
||||||
);
|
);
|
||||||
throw new ForbiddenException(
|
throw new ForbiddenException(
|
||||||
canInviteResult.message ||
|
canInviteResult.message ||
|
||||||
`License limit reached. Please upgrade your subscription to invite more users.`
|
`License limit reached. Please upgrade your subscription to invite more users.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -4,14 +4,24 @@
|
|||||||
* Business logic for subscription and license management.
|
* Business logic for subscription and license management.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Injectable, Inject, Logger, NotFoundException, BadRequestException } from '@nestjs/common';
|
import {
|
||||||
|
Injectable,
|
||||||
|
Inject,
|
||||||
|
Logger,
|
||||||
|
NotFoundException,
|
||||||
|
BadRequestException,
|
||||||
|
ForbiddenException,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import {
|
import {
|
||||||
SubscriptionRepository,
|
SubscriptionRepository,
|
||||||
SUBSCRIPTION_REPOSITORY,
|
SUBSCRIPTION_REPOSITORY,
|
||||||
} from '@domain/ports/out/subscription.repository';
|
} from '@domain/ports/out/subscription.repository';
|
||||||
import { LicenseRepository, LICENSE_REPOSITORY } from '@domain/ports/out/license.repository';
|
import {
|
||||||
|
LicenseRepository,
|
||||||
|
LICENSE_REPOSITORY,
|
||||||
|
} from '@domain/ports/out/license.repository';
|
||||||
import {
|
import {
|
||||||
OrganizationRepository,
|
OrganizationRepository,
|
||||||
ORGANIZATION_REPOSITORY,
|
ORGANIZATION_REPOSITORY,
|
||||||
@ -20,10 +30,14 @@ import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.reposito
|
|||||||
import { StripePort, STRIPE_PORT } from '@domain/ports/out/stripe.port';
|
import { StripePort, STRIPE_PORT } from '@domain/ports/out/stripe.port';
|
||||||
import { Subscription } from '@domain/entities/subscription.entity';
|
import { Subscription } from '@domain/entities/subscription.entity';
|
||||||
import { License } from '@domain/entities/license.entity';
|
import { License } from '@domain/entities/license.entity';
|
||||||
import { SubscriptionPlan, SubscriptionPlanType } from '@domain/value-objects/subscription-plan.vo';
|
import {
|
||||||
|
SubscriptionPlan,
|
||||||
|
SubscriptionPlanType,
|
||||||
|
} from '@domain/value-objects/subscription-plan.vo';
|
||||||
import { SubscriptionStatus } from '@domain/value-objects/subscription-status.vo';
|
import { SubscriptionStatus } from '@domain/value-objects/subscription-status.vo';
|
||||||
import {
|
import {
|
||||||
NoLicensesAvailableException,
|
NoLicensesAvailableException,
|
||||||
|
SubscriptionNotFoundException,
|
||||||
LicenseAlreadyAssignedException,
|
LicenseAlreadyAssignedException,
|
||||||
} from '@domain/exceptions/subscription.exceptions';
|
} from '@domain/exceptions/subscription.exceptions';
|
||||||
import {
|
import {
|
||||||
@ -55,54 +69,50 @@ export class SubscriptionService {
|
|||||||
private readonly userRepository: UserRepository,
|
private readonly userRepository: UserRepository,
|
||||||
@Inject(STRIPE_PORT)
|
@Inject(STRIPE_PORT)
|
||||||
private readonly stripeAdapter: StripePort,
|
private readonly stripeAdapter: StripePort,
|
||||||
private readonly configService: ConfigService
|
private readonly configService: ConfigService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get subscription overview for an organization
|
* Get subscription overview for an organization
|
||||||
* ADMIN users always see a PLATINIUM plan with no expiration
|
|
||||||
*/
|
*/
|
||||||
async getSubscriptionOverview(
|
async getSubscriptionOverview(
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
userRole?: string
|
|
||||||
): Promise<SubscriptionOverviewResponseDto> {
|
): Promise<SubscriptionOverviewResponseDto> {
|
||||||
const subscription = await this.getOrCreateSubscription(organizationId);
|
const subscription = await this.getOrCreateSubscription(organizationId);
|
||||||
const activeLicenses = await this.licenseRepository.findActiveBySubscriptionId(subscription.id);
|
const activeLicenses = await this.licenseRepository.findActiveBySubscriptionId(
|
||||||
|
subscription.id,
|
||||||
|
);
|
||||||
|
|
||||||
// Enrich licenses with user information
|
// Enrich licenses with user information
|
||||||
const enrichedLicenses = await Promise.all(
|
const enrichedLicenses = await Promise.all(
|
||||||
activeLicenses.map(async license => {
|
activeLicenses.map(async (license) => {
|
||||||
const user = await this.userRepository.findById(license.userId);
|
const user = await this.userRepository.findById(license.userId);
|
||||||
return this.mapLicenseToDto(license, user);
|
return this.mapLicenseToDto(license, user);
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Count only non-ADMIN licenses for quota calculation
|
// Count only non-ADMIN licenses for quota calculation
|
||||||
// ADMIN users have unlimited licenses and don't count against the quota
|
// ADMIN users have unlimited licenses and don't count against the quota
|
||||||
const usedLicenses = await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins(
|
const usedLicenses = await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins(
|
||||||
subscription.id
|
subscription.id,
|
||||||
);
|
);
|
||||||
|
const maxLicenses = subscription.maxLicenses;
|
||||||
// ADMIN users always have PLATINIUM plan with no expiration
|
const availableLicenses = subscription.isUnlimited()
|
||||||
const isAdmin = userRole === 'ADMIN';
|
|
||||||
const effectivePlan = isAdmin ? SubscriptionPlan.platinium() : subscription.plan;
|
|
||||||
const maxLicenses = effectivePlan.maxLicenses;
|
|
||||||
const availableLicenses = effectivePlan.isUnlimited()
|
|
||||||
? -1
|
? -1
|
||||||
: Math.max(0, maxLicenses - usedLicenses);
|
: Math.max(0, maxLicenses - usedLicenses);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: subscription.id,
|
id: subscription.id,
|
||||||
organizationId: subscription.organizationId,
|
organizationId: subscription.organizationId,
|
||||||
plan: effectivePlan.value as SubscriptionPlanDto,
|
plan: subscription.plan.value as SubscriptionPlanDto,
|
||||||
planDetails: this.mapPlanToDto(effectivePlan),
|
planDetails: this.mapPlanToDto(subscription.plan),
|
||||||
status: subscription.status.value as SubscriptionStatusDto,
|
status: subscription.status.value as SubscriptionStatusDto,
|
||||||
usedLicenses,
|
usedLicenses,
|
||||||
maxLicenses,
|
maxLicenses,
|
||||||
availableLicenses,
|
availableLicenses,
|
||||||
cancelAtPeriodEnd: false,
|
cancelAtPeriodEnd: subscription.cancelAtPeriodEnd,
|
||||||
currentPeriodStart: isAdmin ? undefined : subscription.currentPeriodStart || undefined,
|
currentPeriodStart: subscription.currentPeriodStart || undefined,
|
||||||
currentPeriodEnd: isAdmin ? undefined : subscription.currentPeriodEnd || undefined,
|
currentPeriodEnd: subscription.currentPeriodEnd || undefined,
|
||||||
createdAt: subscription.createdAt,
|
createdAt: subscription.createdAt,
|
||||||
updatedAt: subscription.updatedAt,
|
updatedAt: subscription.updatedAt,
|
||||||
licenses: enrichedLicenses,
|
licenses: enrichedLicenses,
|
||||||
@ -113,35 +123,27 @@ export class SubscriptionService {
|
|||||||
* Get all available plans
|
* Get all available plans
|
||||||
*/
|
*/
|
||||||
getAllPlans(): AllPlansResponseDto {
|
getAllPlans(): AllPlansResponseDto {
|
||||||
const plans = SubscriptionPlan.getAllPlans().map(plan => this.mapPlanToDto(plan));
|
const plans = SubscriptionPlan.getAllPlans().map((plan) =>
|
||||||
|
this.mapPlanToDto(plan),
|
||||||
|
);
|
||||||
return { plans };
|
return { plans };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if organization can invite more users
|
* Check if organization can invite more users
|
||||||
* Note: ADMIN users don't count against the license quota and always have unlimited licenses
|
* Note: ADMIN users don't count against the license quota
|
||||||
*/
|
*/
|
||||||
async canInviteUser(organizationId: string, userRole?: string): Promise<CanInviteResponseDto> {
|
async canInviteUser(organizationId: string): Promise<CanInviteResponseDto> {
|
||||||
// ADMIN users always have unlimited invitations
|
|
||||||
if (userRole === 'ADMIN') {
|
|
||||||
return {
|
|
||||||
canInvite: true,
|
|
||||||
availableLicenses: -1,
|
|
||||||
usedLicenses: 0,
|
|
||||||
maxLicenses: -1,
|
|
||||||
message: undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const subscription = await this.getOrCreateSubscription(organizationId);
|
const subscription = await this.getOrCreateSubscription(organizationId);
|
||||||
// Count only non-ADMIN licenses - ADMIN users have unlimited licenses
|
// Count only non-ADMIN licenses - ADMIN users have unlimited licenses
|
||||||
const usedLicenses = await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins(
|
const usedLicenses = await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins(
|
||||||
subscription.id
|
subscription.id,
|
||||||
);
|
);
|
||||||
|
|
||||||
const maxLicenses = subscription.maxLicenses;
|
const maxLicenses = subscription.maxLicenses;
|
||||||
const canInvite =
|
const canInvite =
|
||||||
subscription.isActive() && (subscription.isUnlimited() || usedLicenses < maxLicenses);
|
subscription.isActive() &&
|
||||||
|
(subscription.isUnlimited() || usedLicenses < maxLicenses);
|
||||||
|
|
||||||
const availableLicenses = subscription.isUnlimited()
|
const availableLicenses = subscription.isUnlimited()
|
||||||
? -1
|
? -1
|
||||||
@ -169,7 +171,7 @@ export class SubscriptionService {
|
|||||||
async createCheckoutSession(
|
async createCheckoutSession(
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
dto: CreateCheckoutSessionDto
|
dto: CreateCheckoutSessionDto,
|
||||||
): Promise<CheckoutSessionResponseDto> {
|
): Promise<CheckoutSessionResponseDto> {
|
||||||
const organization = await this.organizationRepository.findById(organizationId);
|
const organization = await this.organizationRepository.findById(organizationId);
|
||||||
if (!organization) {
|
if (!organization) {
|
||||||
@ -182,19 +184,23 @@ export class SubscriptionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Cannot checkout for FREE plan
|
// Cannot checkout for FREE plan
|
||||||
if (dto.plan === SubscriptionPlanDto.BRONZE) {
|
if (dto.plan === SubscriptionPlanDto.FREE) {
|
||||||
throw new BadRequestException('Cannot create checkout session for Bronze plan');
|
throw new BadRequestException('Cannot create checkout session for FREE plan');
|
||||||
}
|
}
|
||||||
|
|
||||||
const subscription = await this.getOrCreateSubscription(organizationId);
|
const subscription = await this.getOrCreateSubscription(organizationId);
|
||||||
|
|
||||||
const frontendUrl = this.configService.get<string>('FRONTEND_URL', 'http://localhost:3000');
|
const frontendUrl = this.configService.get<string>(
|
||||||
|
'FRONTEND_URL',
|
||||||
|
'http://localhost:3000',
|
||||||
|
);
|
||||||
// Include {CHECKOUT_SESSION_ID} placeholder - Stripe replaces it with actual session ID
|
// Include {CHECKOUT_SESSION_ID} placeholder - Stripe replaces it with actual session ID
|
||||||
const successUrl =
|
const successUrl =
|
||||||
dto.successUrl ||
|
dto.successUrl ||
|
||||||
`${frontendUrl}/dashboard/settings/organization?success=true&session_id={CHECKOUT_SESSION_ID}`;
|
`${frontendUrl}/dashboard/settings/organization?success=true&session_id={CHECKOUT_SESSION_ID}`;
|
||||||
const cancelUrl =
|
const cancelUrl =
|
||||||
dto.cancelUrl || `${frontendUrl}/dashboard/settings/organization?canceled=true`;
|
dto.cancelUrl ||
|
||||||
|
`${frontendUrl}/dashboard/settings/organization?canceled=true`;
|
||||||
|
|
||||||
const result = await this.stripeAdapter.createCheckoutSession({
|
const result = await this.stripeAdapter.createCheckoutSession({
|
||||||
organizationId,
|
organizationId,
|
||||||
@ -208,7 +214,7 @@ export class SubscriptionService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Created checkout session for organization ${organizationId}, plan ${dto.plan}`
|
`Created checkout session for organization ${organizationId}, plan ${dto.plan}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -222,18 +228,24 @@ export class SubscriptionService {
|
|||||||
*/
|
*/
|
||||||
async createPortalSession(
|
async createPortalSession(
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
dto: CreatePortalSessionDto
|
dto: CreatePortalSessionDto,
|
||||||
): Promise<PortalSessionResponseDto> {
|
): Promise<PortalSessionResponseDto> {
|
||||||
const subscription = await this.subscriptionRepository.findByOrganizationId(organizationId);
|
const subscription = await this.subscriptionRepository.findByOrganizationId(
|
||||||
|
organizationId,
|
||||||
|
);
|
||||||
|
|
||||||
if (!subscription?.stripeCustomerId) {
|
if (!subscription?.stripeCustomerId) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
'No Stripe customer found for this organization. Please complete a checkout first.'
|
'No Stripe customer found for this organization. Please complete a checkout first.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const frontendUrl = this.configService.get<string>('FRONTEND_URL', 'http://localhost:3000');
|
const frontendUrl = this.configService.get<string>(
|
||||||
const returnUrl = dto.returnUrl || `${frontendUrl}/dashboard/settings/organization`;
|
'FRONTEND_URL',
|
||||||
|
'http://localhost:3000',
|
||||||
|
);
|
||||||
|
const returnUrl =
|
||||||
|
dto.returnUrl || `${frontendUrl}/dashboard/settings/organization`;
|
||||||
|
|
||||||
const result = await this.stripeAdapter.createPortalSession({
|
const result = await this.stripeAdapter.createPortalSession({
|
||||||
customerId: subscription.stripeCustomerId,
|
customerId: subscription.stripeCustomerId,
|
||||||
@ -255,9 +267,11 @@ export class SubscriptionService {
|
|||||||
*/
|
*/
|
||||||
async syncFromStripe(
|
async syncFromStripe(
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
sessionId?: string
|
sessionId?: string,
|
||||||
): Promise<SubscriptionOverviewResponseDto> {
|
): Promise<SubscriptionOverviewResponseDto> {
|
||||||
let subscription = await this.subscriptionRepository.findByOrganizationId(organizationId);
|
let subscription = await this.subscriptionRepository.findByOrganizationId(
|
||||||
|
organizationId,
|
||||||
|
);
|
||||||
|
|
||||||
if (!subscription) {
|
if (!subscription) {
|
||||||
subscription = await this.getOrCreateSubscription(organizationId);
|
subscription = await this.getOrCreateSubscription(organizationId);
|
||||||
@ -269,14 +283,12 @@ export class SubscriptionService {
|
|||||||
// If we have a session ID, ALWAYS retrieve the checkout session to get the latest subscription details
|
// If we have a session ID, ALWAYS retrieve the checkout session to get the latest subscription details
|
||||||
// This is important for upgrades where Stripe may create a new subscription
|
// This is important for upgrades where Stripe may create a new subscription
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
this.logger.log(
|
this.logger.log(`Retrieving checkout session ${sessionId} for organization ${organizationId}`);
|
||||||
`Retrieving checkout session ${sessionId} for organization ${organizationId}`
|
|
||||||
);
|
|
||||||
const checkoutSession = await this.stripeAdapter.getCheckoutSession(sessionId);
|
const checkoutSession = await this.stripeAdapter.getCheckoutSession(sessionId);
|
||||||
|
|
||||||
if (checkoutSession) {
|
if (checkoutSession) {
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Checkout session found: subscriptionId=${checkoutSession.subscriptionId}, customerId=${checkoutSession.customerId}, status=${checkoutSession.status}`
|
`Checkout session found: subscriptionId=${checkoutSession.subscriptionId}, customerId=${checkoutSession.customerId}, status=${checkoutSession.status}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Always use the subscription ID from the checkout session if available
|
// Always use the subscription ID from the checkout session if available
|
||||||
@ -318,7 +330,7 @@ export class SubscriptionService {
|
|||||||
if (plan) {
|
if (plan) {
|
||||||
// Count only non-ADMIN licenses - ADMIN users have unlimited licenses
|
// Count only non-ADMIN licenses - ADMIN users have unlimited licenses
|
||||||
const usedLicenses = await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins(
|
const usedLicenses = await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins(
|
||||||
subscription.id
|
subscription.id,
|
||||||
);
|
);
|
||||||
const newPlan = SubscriptionPlan.create(plan);
|
const newPlan = SubscriptionPlan.create(plan);
|
||||||
|
|
||||||
@ -342,13 +354,13 @@ export class SubscriptionService {
|
|||||||
|
|
||||||
// Update status
|
// Update status
|
||||||
updatedSubscription = updatedSubscription.updateStatus(
|
updatedSubscription = updatedSubscription.updateStatus(
|
||||||
SubscriptionStatus.fromStripeStatus(stripeData.status)
|
SubscriptionStatus.fromStripeStatus(stripeData.status),
|
||||||
);
|
);
|
||||||
|
|
||||||
await this.subscriptionRepository.save(updatedSubscription);
|
await this.subscriptionRepository.save(updatedSubscription);
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Synced subscription for organization ${organizationId} from Stripe (plan: ${updatedSubscription.plan.value})`
|
`Synced subscription for organization ${organizationId} from Stripe (plan: ${updatedSubscription.plan.value})`,
|
||||||
);
|
);
|
||||||
|
|
||||||
return this.getSubscriptionOverview(organizationId);
|
return this.getSubscriptionOverview(organizationId);
|
||||||
@ -406,14 +418,14 @@ export class SubscriptionService {
|
|||||||
if (!isAdmin) {
|
if (!isAdmin) {
|
||||||
// Count only non-ADMIN licenses for quota check
|
// Count only non-ADMIN licenses for quota check
|
||||||
const usedLicenses = await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins(
|
const usedLicenses = await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins(
|
||||||
subscription.id
|
subscription.id,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!subscription.canAllocateLicenses(usedLicenses)) {
|
if (!subscription.canAllocateLicenses(usedLicenses)) {
|
||||||
throw new NoLicensesAvailableException(
|
throw new NoLicensesAvailableException(
|
||||||
organizationId,
|
organizationId,
|
||||||
usedLicenses,
|
usedLicenses,
|
||||||
subscription.maxLicenses
|
subscription.maxLicenses,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -462,18 +474,22 @@ export class SubscriptionService {
|
|||||||
* Get or create a subscription for an organization
|
* Get or create a subscription for an organization
|
||||||
*/
|
*/
|
||||||
async getOrCreateSubscription(organizationId: string): Promise<Subscription> {
|
async getOrCreateSubscription(organizationId: string): Promise<Subscription> {
|
||||||
let subscription = await this.subscriptionRepository.findByOrganizationId(organizationId);
|
let subscription = await this.subscriptionRepository.findByOrganizationId(
|
||||||
|
organizationId,
|
||||||
|
);
|
||||||
|
|
||||||
if (!subscription) {
|
if (!subscription) {
|
||||||
// Create FREE subscription for the organization
|
// Create FREE subscription for the organization
|
||||||
subscription = Subscription.create({
|
subscription = Subscription.create({
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
organizationId,
|
organizationId,
|
||||||
plan: SubscriptionPlan.bronze(),
|
plan: SubscriptionPlan.free(),
|
||||||
});
|
});
|
||||||
|
|
||||||
subscription = await this.subscriptionRepository.save(subscription);
|
subscription = await this.subscriptionRepository.save(subscription);
|
||||||
this.logger.log(`Created Bronze subscription for organization ${organizationId}`);
|
this.logger.log(
|
||||||
|
`Created FREE subscription for organization ${organizationId}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return subscription;
|
return subscription;
|
||||||
@ -481,7 +497,9 @@ export class SubscriptionService {
|
|||||||
|
|
||||||
// Private helper methods
|
// Private helper methods
|
||||||
|
|
||||||
private async handleCheckoutCompleted(session: Record<string, unknown>): Promise<void> {
|
private async handleCheckoutCompleted(
|
||||||
|
session: Record<string, unknown>,
|
||||||
|
): Promise<void> {
|
||||||
const metadata = session.metadata as Record<string, string> | undefined;
|
const metadata = session.metadata as Record<string, string> | undefined;
|
||||||
const organizationId = metadata?.organizationId;
|
const organizationId = metadata?.organizationId;
|
||||||
const customerId = session.customer as string;
|
const customerId = session.customer as string;
|
||||||
@ -519,26 +537,27 @@ export class SubscriptionService {
|
|||||||
});
|
});
|
||||||
subscription = subscription.updatePlan(
|
subscription = subscription.updatePlan(
|
||||||
SubscriptionPlan.create(plan),
|
SubscriptionPlan.create(plan),
|
||||||
await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins(subscription.id)
|
await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins(subscription.id),
|
||||||
);
|
);
|
||||||
subscription = subscription.updateStatus(
|
subscription = subscription.updateStatus(
|
||||||
SubscriptionStatus.fromStripeStatus(stripeSubscription.status)
|
SubscriptionStatus.fromStripeStatus(stripeSubscription.status),
|
||||||
);
|
);
|
||||||
|
|
||||||
await this.subscriptionRepository.save(subscription);
|
await this.subscriptionRepository.save(subscription);
|
||||||
|
|
||||||
// Update organization status badge to match the plan
|
this.logger.log(
|
||||||
await this.updateOrganizationBadge(organizationId, subscription.statusBadge);
|
`Updated subscription for organization ${organizationId} to plan ${plan}`,
|
||||||
|
);
|
||||||
this.logger.log(`Updated subscription for organization ${organizationId} to plan ${plan}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleSubscriptionUpdated(
|
private async handleSubscriptionUpdated(
|
||||||
stripeSubscription: Record<string, unknown>
|
stripeSubscription: Record<string, unknown>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const subscriptionId = stripeSubscription.id as string;
|
const subscriptionId = stripeSubscription.id as string;
|
||||||
|
|
||||||
let subscription = await this.subscriptionRepository.findByStripeSubscriptionId(subscriptionId);
|
let subscription = await this.subscriptionRepository.findByStripeSubscriptionId(
|
||||||
|
subscriptionId,
|
||||||
|
);
|
||||||
|
|
||||||
if (!subscription) {
|
if (!subscription) {
|
||||||
this.logger.warn(`Subscription ${subscriptionId} not found in database`);
|
this.logger.warn(`Subscription ${subscriptionId} not found in database`);
|
||||||
@ -557,7 +576,7 @@ export class SubscriptionService {
|
|||||||
if (plan) {
|
if (plan) {
|
||||||
// Count only non-ADMIN licenses - ADMIN users have unlimited licenses
|
// Count only non-ADMIN licenses - ADMIN users have unlimited licenses
|
||||||
const usedLicenses = await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins(
|
const usedLicenses = await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins(
|
||||||
subscription.id
|
subscription.id,
|
||||||
);
|
);
|
||||||
const newPlan = SubscriptionPlan.create(plan);
|
const newPlan = SubscriptionPlan.create(plan);
|
||||||
|
|
||||||
@ -565,7 +584,9 @@ export class SubscriptionService {
|
|||||||
if (newPlan.canAccommodateUsers(usedLicenses)) {
|
if (newPlan.canAccommodateUsers(usedLicenses)) {
|
||||||
subscription = subscription.updatePlan(newPlan, usedLicenses);
|
subscription = subscription.updatePlan(newPlan, usedLicenses);
|
||||||
} else {
|
} else {
|
||||||
this.logger.warn(`Cannot update to plan ${plan} - would exceed license limit`);
|
this.logger.warn(
|
||||||
|
`Cannot update to plan ${plan} - would exceed license limit`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -576,26 +597,22 @@ export class SubscriptionService {
|
|||||||
cancelAtPeriodEnd: stripeData.cancelAtPeriodEnd,
|
cancelAtPeriodEnd: stripeData.cancelAtPeriodEnd,
|
||||||
});
|
});
|
||||||
subscription = subscription.updateStatus(
|
subscription = subscription.updateStatus(
|
||||||
SubscriptionStatus.fromStripeStatus(stripeData.status)
|
SubscriptionStatus.fromStripeStatus(stripeData.status),
|
||||||
);
|
);
|
||||||
|
|
||||||
await this.subscriptionRepository.save(subscription);
|
await this.subscriptionRepository.save(subscription);
|
||||||
|
|
||||||
// Update organization status badge to match the plan
|
|
||||||
if (subscription.organizationId) {
|
|
||||||
await this.updateOrganizationBadge(subscription.organizationId, subscription.statusBadge);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.log(`Updated subscription ${subscriptionId}`);
|
this.logger.log(`Updated subscription ${subscriptionId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleSubscriptionDeleted(
|
private async handleSubscriptionDeleted(
|
||||||
stripeSubscription: Record<string, unknown>
|
stripeSubscription: Record<string, unknown>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const subscriptionId = stripeSubscription.id as string;
|
const subscriptionId = stripeSubscription.id as string;
|
||||||
|
|
||||||
const subscription =
|
const subscription = await this.subscriptionRepository.findByStripeSubscriptionId(
|
||||||
await this.subscriptionRepository.findByStripeSubscriptionId(subscriptionId);
|
subscriptionId,
|
||||||
|
);
|
||||||
|
|
||||||
if (!subscription) {
|
if (!subscription) {
|
||||||
this.logger.warn(`Subscription ${subscriptionId} not found in database`);
|
this.logger.warn(`Subscription ${subscriptionId} not found in database`);
|
||||||
@ -605,41 +622,42 @@ export class SubscriptionService {
|
|||||||
// Downgrade to FREE plan - count only non-ADMIN licenses
|
// Downgrade to FREE plan - count only non-ADMIN licenses
|
||||||
const canceledSubscription = subscription
|
const canceledSubscription = subscription
|
||||||
.updatePlan(
|
.updatePlan(
|
||||||
SubscriptionPlan.bronze(),
|
SubscriptionPlan.free(),
|
||||||
await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins(subscription.id)
|
await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins(subscription.id),
|
||||||
)
|
)
|
||||||
.updateStatus(SubscriptionStatus.canceled());
|
.updateStatus(SubscriptionStatus.canceled());
|
||||||
|
|
||||||
await this.subscriptionRepository.save(canceledSubscription);
|
await this.subscriptionRepository.save(canceledSubscription);
|
||||||
|
|
||||||
// Reset organization badge to 'none' on cancellation
|
this.logger.log(`Subscription ${subscriptionId} canceled, downgraded to FREE`);
|
||||||
if (subscription.organizationId) {
|
|
||||||
await this.updateOrganizationBadge(subscription.organizationId, 'none');
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.log(`Subscription ${subscriptionId} canceled, downgraded to Bronze`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handlePaymentFailed(invoice: Record<string, unknown>): Promise<void> {
|
private async handlePaymentFailed(invoice: Record<string, unknown>): Promise<void> {
|
||||||
const customerId = invoice.customer as string;
|
const customerId = invoice.customer as string;
|
||||||
|
|
||||||
const subscription = await this.subscriptionRepository.findByStripeCustomerId(customerId);
|
const subscription = await this.subscriptionRepository.findByStripeCustomerId(
|
||||||
|
customerId,
|
||||||
|
);
|
||||||
|
|
||||||
if (!subscription) {
|
if (!subscription) {
|
||||||
this.logger.warn(`Subscription for customer ${customerId} not found`);
|
this.logger.warn(`Subscription for customer ${customerId} not found`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedSubscription = subscription.updateStatus(SubscriptionStatus.pastDue());
|
const updatedSubscription = subscription.updateStatus(
|
||||||
|
SubscriptionStatus.pastDue(),
|
||||||
|
);
|
||||||
|
|
||||||
await this.subscriptionRepository.save(updatedSubscription);
|
await this.subscriptionRepository.save(updatedSubscription);
|
||||||
|
|
||||||
this.logger.log(`Subscription ${subscription.id} marked as past due due to payment failure`);
|
this.logger.log(
|
||||||
|
`Subscription ${subscription.id} marked as past due due to payment failure`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private mapLicenseToDto(
|
private mapLicenseToDto(
|
||||||
license: License,
|
license: License,
|
||||||
user: { email: string; firstName: string; lastName: string; role: string } | null
|
user: { email: string; firstName: string; lastName: string; role: string } | null,
|
||||||
): LicenseResponseDto {
|
): LicenseResponseDto {
|
||||||
return {
|
return {
|
||||||
id: license.id,
|
id: license.id,
|
||||||
@ -653,19 +671,6 @@ export class SubscriptionService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async updateOrganizationBadge(organizationId: string, badge: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
const organization = await this.organizationRepository.findById(organizationId);
|
|
||||||
if (organization) {
|
|
||||||
organization.updateStatusBadge(badge as 'none' | 'silver' | 'gold' | 'platinium');
|
|
||||||
await this.organizationRepository.save(organization);
|
|
||||||
this.logger.log(`Updated status badge for organization ${organizationId} to ${badge}`);
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`Failed to update organization badge: ${error?.message}`, error?.stack);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private mapPlanToDto(plan: SubscriptionPlan): PlanDetailsDto {
|
private mapPlanToDto(plan: SubscriptionPlan): PlanDetailsDto {
|
||||||
return {
|
return {
|
||||||
plan: plan.value as SubscriptionPlanDto,
|
plan: plan.value as SubscriptionPlanDto,
|
||||||
@ -673,11 +678,6 @@ export class SubscriptionService {
|
|||||||
maxLicenses: plan.maxLicenses,
|
maxLicenses: plan.maxLicenses,
|
||||||
monthlyPriceEur: plan.monthlyPriceEur,
|
monthlyPriceEur: plan.monthlyPriceEur,
|
||||||
yearlyPriceEur: plan.yearlyPriceEur,
|
yearlyPriceEur: plan.yearlyPriceEur,
|
||||||
maxShipmentsPerYear: plan.maxShipmentsPerYear,
|
|
||||||
commissionRatePercent: plan.commissionRatePercent,
|
|
||||||
supportLevel: plan.supportLevel,
|
|
||||||
statusBadge: plan.statusBadge,
|
|
||||||
planFeatures: [...plan.planFeatures],
|
|
||||||
features: [...plan.features],
|
features: [...plan.features],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,13 +7,14 @@ import { USER_REPOSITORY } from '@domain/ports/out/user.repository';
|
|||||||
import { TypeOrmUserRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-user.repository';
|
import { TypeOrmUserRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-user.repository';
|
||||||
import { UserOrmEntity } from '../../infrastructure/persistence/typeorm/entities/user.orm-entity';
|
import { UserOrmEntity } from '../../infrastructure/persistence/typeorm/entities/user.orm-entity';
|
||||||
import { SubscriptionsModule } from '../subscriptions/subscriptions.module';
|
import { SubscriptionsModule } from '../subscriptions/subscriptions.module';
|
||||||
import { FeatureFlagGuard } from '../guards/feature-flag.guard';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([UserOrmEntity]), SubscriptionsModule],
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([UserOrmEntity]),
|
||||||
|
SubscriptionsModule,
|
||||||
|
],
|
||||||
controllers: [UsersController],
|
controllers: [UsersController],
|
||||||
providers: [
|
providers: [
|
||||||
FeatureFlagGuard,
|
|
||||||
{
|
{
|
||||||
provide: USER_REPOSITORY,
|
provide: USER_REPOSITORY,
|
||||||
useClass: TypeOrmUserRepository,
|
useClass: TypeOrmUserRepository,
|
||||||
|
|||||||
@ -50,8 +50,6 @@ export interface BookingProps {
|
|||||||
cargoDescription: string;
|
cargoDescription: string;
|
||||||
containers: BookingContainer[];
|
containers: BookingContainer[];
|
||||||
specialInstructions?: string;
|
specialInstructions?: string;
|
||||||
commissionRate?: number;
|
|
||||||
commissionAmountEur?: number;
|
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
@ -163,14 +161,6 @@ export class Booking {
|
|||||||
return this.props.specialInstructions;
|
return this.props.specialInstructions;
|
||||||
}
|
}
|
||||||
|
|
||||||
get commissionRate(): number | undefined {
|
|
||||||
return this.props.commissionRate;
|
|
||||||
}
|
|
||||||
|
|
||||||
get commissionAmountEur(): number | undefined {
|
|
||||||
return this.props.commissionAmountEur;
|
|
||||||
}
|
|
||||||
|
|
||||||
get createdAt(): Date {
|
get createdAt(): Date {
|
||||||
return this.props.createdAt;
|
return this.props.createdAt;
|
||||||
}
|
}
|
||||||
@ -280,19 +270,6 @@ export class Booking {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Apply commission to the booking
|
|
||||||
*/
|
|
||||||
applyCommission(ratePercent: number, baseAmountEur: number): Booking {
|
|
||||||
const commissionAmount = Math.round(baseAmountEur * ratePercent) / 100;
|
|
||||||
return new Booking({
|
|
||||||
...this.props,
|
|
||||||
commissionRate: ratePercent,
|
|
||||||
commissionAmountEur: commissionAmount,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if booking can be cancelled
|
* Check if booking can be cancelled
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -6,8 +6,6 @@ import { PortCode } from '../value-objects/port-code.vo';
|
|||||||
* Represents the lifecycle of a CSV-based booking request
|
* Represents the lifecycle of a CSV-based booking request
|
||||||
*/
|
*/
|
||||||
export enum CsvBookingStatus {
|
export enum CsvBookingStatus {
|
||||||
PENDING_PAYMENT = 'PENDING_PAYMENT', // Awaiting commission payment
|
|
||||||
PENDING_BANK_TRANSFER = 'PENDING_BANK_TRANSFER', // Bank transfer declared, awaiting admin validation
|
|
||||||
PENDING = 'PENDING', // Awaiting carrier response
|
PENDING = 'PENDING', // Awaiting carrier response
|
||||||
ACCEPTED = 'ACCEPTED', // Carrier accepted the booking
|
ACCEPTED = 'ACCEPTED', // Carrier accepted the booking
|
||||||
REJECTED = 'REJECTED', // Carrier rejected the booking
|
REJECTED = 'REJECTED', // Carrier rejected the booking
|
||||||
@ -82,10 +80,7 @@ export class CsvBooking {
|
|||||||
public respondedAt?: Date,
|
public respondedAt?: Date,
|
||||||
public notes?: string,
|
public notes?: string,
|
||||||
public rejectionReason?: string,
|
public rejectionReason?: string,
|
||||||
public readonly bookingNumber?: string,
|
public readonly bookingNumber?: string
|
||||||
public commissionRate?: number,
|
|
||||||
public commissionAmountEur?: number,
|
|
||||||
public stripePaymentIntentId?: string
|
|
||||||
) {
|
) {
|
||||||
this.validate();
|
this.validate();
|
||||||
}
|
}
|
||||||
@ -149,61 +144,6 @@ export class CsvBooking {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Apply commission to the booking
|
|
||||||
*/
|
|
||||||
applyCommission(ratePercent: number, baseAmountEur: number): void {
|
|
||||||
this.commissionRate = ratePercent;
|
|
||||||
this.commissionAmountEur = Math.round(baseAmountEur * ratePercent) / 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mark commission payment as completed → transition to PENDING
|
|
||||||
*
|
|
||||||
* @throws Error if booking is not in PENDING_PAYMENT status
|
|
||||||
*/
|
|
||||||
markPaymentCompleted(): void {
|
|
||||||
if (this.status !== CsvBookingStatus.PENDING_PAYMENT) {
|
|
||||||
throw new Error(
|
|
||||||
`Cannot mark payment completed for booking with status ${this.status}. Only PENDING_PAYMENT bookings can transition.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.status = CsvBookingStatus.PENDING;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Declare bank transfer → transition to PENDING_BANK_TRANSFER
|
|
||||||
* Called when user confirms they have sent the bank transfer
|
|
||||||
*
|
|
||||||
* @throws Error if booking is not in PENDING_PAYMENT status
|
|
||||||
*/
|
|
||||||
markBankTransferDeclared(): void {
|
|
||||||
if (this.status !== CsvBookingStatus.PENDING_PAYMENT) {
|
|
||||||
throw new Error(
|
|
||||||
`Cannot declare bank transfer for booking with status ${this.status}. Only PENDING_PAYMENT bookings can transition.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.status = CsvBookingStatus.PENDING_BANK_TRANSFER;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Admin validates bank transfer → transition to PENDING
|
|
||||||
* Called by admin once bank transfer has been received and verified
|
|
||||||
*
|
|
||||||
* @throws Error if booking is not in PENDING_BANK_TRANSFER status
|
|
||||||
*/
|
|
||||||
markBankTransferValidated(): void {
|
|
||||||
if (this.status !== CsvBookingStatus.PENDING_BANK_TRANSFER) {
|
|
||||||
throw new Error(
|
|
||||||
`Cannot validate bank transfer for booking with status ${this.status}. Only PENDING_BANK_TRANSFER bookings can transition.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.status = CsvBookingStatus.PENDING;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Accept the booking
|
* Accept the booking
|
||||||
*
|
*
|
||||||
@ -262,10 +202,6 @@ export class CsvBooking {
|
|||||||
throw new Error('Cannot cancel rejected booking');
|
throw new Error('Cannot cancel rejected booking');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.status === CsvBookingStatus.CANCELLED) {
|
|
||||||
throw new Error('Booking is already cancelled');
|
|
||||||
}
|
|
||||||
|
|
||||||
this.status = CsvBookingStatus.CANCELLED;
|
this.status = CsvBookingStatus.CANCELLED;
|
||||||
this.respondedAt = new Date();
|
this.respondedAt = new Date();
|
||||||
}
|
}
|
||||||
@ -275,10 +211,6 @@ export class CsvBooking {
|
|||||||
*
|
*
|
||||||
* @returns true if booking is older than 7 days and still pending
|
* @returns true if booking is older than 7 days and still pending
|
||||||
*/
|
*/
|
||||||
isPendingPayment(): boolean {
|
|
||||||
return this.status === CsvBookingStatus.PENDING_PAYMENT;
|
|
||||||
}
|
|
||||||
|
|
||||||
isExpired(): boolean {
|
isExpired(): boolean {
|
||||||
if (this.status !== CsvBookingStatus.PENDING) {
|
if (this.status !== CsvBookingStatus.PENDING) {
|
||||||
return false;
|
return false;
|
||||||
@ -431,10 +363,7 @@ export class CsvBooking {
|
|||||||
respondedAt?: Date,
|
respondedAt?: Date,
|
||||||
notes?: string,
|
notes?: string,
|
||||||
rejectionReason?: string,
|
rejectionReason?: string,
|
||||||
bookingNumber?: string,
|
bookingNumber?: string
|
||||||
commissionRate?: number,
|
|
||||||
commissionAmountEur?: number,
|
|
||||||
stripePaymentIntentId?: string
|
|
||||||
): CsvBooking {
|
): CsvBooking {
|
||||||
// Create instance without calling constructor validation
|
// Create instance without calling constructor validation
|
||||||
const booking = Object.create(CsvBooking.prototype);
|
const booking = Object.create(CsvBooking.prototype);
|
||||||
@ -463,9 +392,6 @@ export class CsvBooking {
|
|||||||
booking.notes = notes;
|
booking.notes = notes;
|
||||||
booking.rejectionReason = rejectionReason;
|
booking.rejectionReason = rejectionReason;
|
||||||
booking.bookingNumber = bookingNumber;
|
booking.bookingNumber = bookingNumber;
|
||||||
booking.commissionRate = commissionRate;
|
|
||||||
booking.commissionAmountEur = commissionAmountEur;
|
|
||||||
booking.stripePaymentIntentId = stripePaymentIntentId;
|
|
||||||
|
|
||||||
return booking;
|
return booking;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,7 +5,10 @@
|
|||||||
* Each active user in an organization consumes one license.
|
* Each active user in an organization consumes one license.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { LicenseStatus, LicenseStatusType } from '../value-objects/license-status.vo';
|
import {
|
||||||
|
LicenseStatus,
|
||||||
|
LicenseStatusType,
|
||||||
|
} from '../value-objects/license-status.vo';
|
||||||
|
|
||||||
export interface LicenseProps {
|
export interface LicenseProps {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
@ -26,7 +29,11 @@ export class License {
|
|||||||
/**
|
/**
|
||||||
* Create a new license for a user
|
* Create a new license for a user
|
||||||
*/
|
*/
|
||||||
static create(props: { id: string; subscriptionId: string; userId: string }): License {
|
static create(props: {
|
||||||
|
id: string;
|
||||||
|
subscriptionId: string;
|
||||||
|
userId: string;
|
||||||
|
}): License {
|
||||||
return new License({
|
return new License({
|
||||||
id: props.id,
|
id: props.id,
|
||||||
subscriptionId: props.subscriptionId,
|
subscriptionId: props.subscriptionId,
|
||||||
|
|||||||
@ -44,9 +44,6 @@ export interface OrganizationProps {
|
|||||||
address: OrganizationAddress;
|
address: OrganizationAddress;
|
||||||
logoUrl?: string;
|
logoUrl?: string;
|
||||||
documents: OrganizationDocument[];
|
documents: OrganizationDocument[];
|
||||||
siret?: string;
|
|
||||||
siretVerified: boolean;
|
|
||||||
statusBadge: 'none' | 'silver' | 'gold' | 'platinium';
|
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
@ -62,19 +59,9 @@ export class Organization {
|
|||||||
/**
|
/**
|
||||||
* Factory method to create a new Organization
|
* Factory method to create a new Organization
|
||||||
*/
|
*/
|
||||||
static create(
|
static create(props: Omit<OrganizationProps, 'createdAt' | 'updatedAt'>): Organization {
|
||||||
props: Omit<OrganizationProps, 'createdAt' | 'updatedAt' | 'siretVerified' | 'statusBadge'> & {
|
|
||||||
siretVerified?: boolean;
|
|
||||||
statusBadge?: 'none' | 'silver' | 'gold' | 'platinium';
|
|
||||||
}
|
|
||||||
): Organization {
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
// Validate SIRET if provided
|
|
||||||
if (props.siret && !Organization.isValidSiret(props.siret)) {
|
|
||||||
throw new Error('Invalid SIRET format. Must be 14 digits.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate SCAC code if provided
|
// Validate SCAC code if provided
|
||||||
if (props.scac && !Organization.isValidSCAC(props.scac)) {
|
if (props.scac && !Organization.isValidSCAC(props.scac)) {
|
||||||
throw new Error('Invalid SCAC code format. Must be 4 uppercase letters.');
|
throw new Error('Invalid SCAC code format. Must be 4 uppercase letters.');
|
||||||
@ -92,8 +79,6 @@ export class Organization {
|
|||||||
|
|
||||||
return new Organization({
|
return new Organization({
|
||||||
...props,
|
...props,
|
||||||
siretVerified: props.siretVerified ?? false,
|
|
||||||
statusBadge: props.statusBadge ?? 'none',
|
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
});
|
});
|
||||||
@ -115,10 +100,6 @@ export class Organization {
|
|||||||
return scacPattern.test(scac);
|
return scacPattern.test(scac);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static isValidSiret(siret: string): boolean {
|
|
||||||
return /^\d{14}$/.test(siret);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Getters
|
// Getters
|
||||||
get id(): string {
|
get id(): string {
|
||||||
return this.props.id;
|
return this.props.id;
|
||||||
@ -172,18 +153,6 @@ export class Organization {
|
|||||||
return this.props.updatedAt;
|
return this.props.updatedAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
get siret(): string | undefined {
|
|
||||||
return this.props.siret;
|
|
||||||
}
|
|
||||||
|
|
||||||
get siretVerified(): boolean {
|
|
||||||
return this.props.siretVerified;
|
|
||||||
}
|
|
||||||
|
|
||||||
get statusBadge(): 'none' | 'silver' | 'gold' | 'platinium' {
|
|
||||||
return this.props.statusBadge;
|
|
||||||
}
|
|
||||||
|
|
||||||
get isActive(): boolean {
|
get isActive(): boolean {
|
||||||
return this.props.isActive;
|
return this.props.isActive;
|
||||||
}
|
}
|
||||||
@ -214,25 +183,6 @@ export class Organization {
|
|||||||
this.props.updatedAt = new Date();
|
this.props.updatedAt = new Date();
|
||||||
}
|
}
|
||||||
|
|
||||||
updateSiret(siret: string): void {
|
|
||||||
if (!Organization.isValidSiret(siret)) {
|
|
||||||
throw new Error('Invalid SIRET format. Must be 14 digits.');
|
|
||||||
}
|
|
||||||
this.props.siret = siret;
|
|
||||||
this.props.siretVerified = false;
|
|
||||||
this.props.updatedAt = new Date();
|
|
||||||
}
|
|
||||||
|
|
||||||
markSiretVerified(): void {
|
|
||||||
this.props.siretVerified = true;
|
|
||||||
this.props.updatedAt = new Date();
|
|
||||||
}
|
|
||||||
|
|
||||||
updateStatusBadge(badge: 'none' | 'silver' | 'gold' | 'platinium'): void {
|
|
||||||
this.props.statusBadge = badge;
|
|
||||||
this.props.updatedAt = new Date();
|
|
||||||
}
|
|
||||||
|
|
||||||
updateSiren(siren: string): void {
|
updateSiren(siren: string): void {
|
||||||
this.props.siren = siren;
|
this.props.siren = siren;
|
||||||
this.props.updatedAt = new Date();
|
this.props.updatedAt = new Date();
|
||||||
|
|||||||
@ -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,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -5,7 +5,10 @@
|
|||||||
* Stripe integration, and billing period information.
|
* Stripe integration, and billing period information.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { SubscriptionPlan, SubscriptionPlanType } from '../value-objects/subscription-plan.vo';
|
import {
|
||||||
|
SubscriptionPlan,
|
||||||
|
SubscriptionPlanType,
|
||||||
|
} from '../value-objects/subscription-plan.vo';
|
||||||
import {
|
import {
|
||||||
SubscriptionStatus,
|
SubscriptionStatus,
|
||||||
SubscriptionStatusType,
|
SubscriptionStatusType,
|
||||||
@ -37,7 +40,7 @@ export class Subscription {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new subscription (defaults to Bronze/free plan)
|
* Create a new subscription (defaults to FREE plan)
|
||||||
*/
|
*/
|
||||||
static create(props: {
|
static create(props: {
|
||||||
id: string;
|
id: string;
|
||||||
@ -50,7 +53,7 @@ export class Subscription {
|
|||||||
return new Subscription({
|
return new Subscription({
|
||||||
id: props.id,
|
id: props.id,
|
||||||
organizationId: props.organizationId,
|
organizationId: props.organizationId,
|
||||||
plan: props.plan ?? SubscriptionPlan.bronze(),
|
plan: props.plan ?? SubscriptionPlan.free(),
|
||||||
status: SubscriptionStatus.active(),
|
status: SubscriptionStatus.active(),
|
||||||
stripeCustomerId: props.stripeCustomerId ?? null,
|
stripeCustomerId: props.stripeCustomerId ?? null,
|
||||||
stripeSubscriptionId: props.stripeSubscriptionId ?? null,
|
stripeSubscriptionId: props.stripeSubscriptionId ?? null,
|
||||||
@ -65,41 +68,10 @@ export class Subscription {
|
|||||||
/**
|
/**
|
||||||
* Reconstitute from persistence
|
* Reconstitute from persistence
|
||||||
*/
|
*/
|
||||||
/**
|
|
||||||
* Check if a specific plan feature is available
|
|
||||||
*/
|
|
||||||
hasFeature(feature: import('../value-objects/plan-feature.vo').PlanFeature): boolean {
|
|
||||||
return this.props.plan.hasFeature(feature);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the maximum shipments per year allowed
|
|
||||||
*/
|
|
||||||
get maxShipmentsPerYear(): number {
|
|
||||||
return this.props.plan.maxShipmentsPerYear;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the commission rate for this subscription's plan
|
|
||||||
*/
|
|
||||||
get commissionRatePercent(): number {
|
|
||||||
return this.props.plan.commissionRatePercent;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the status badge for this subscription's plan
|
|
||||||
*/
|
|
||||||
get statusBadge(): string {
|
|
||||||
return this.props.plan.statusBadge;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reconstitute from persistence (supports legacy plan names)
|
|
||||||
*/
|
|
||||||
static fromPersistence(props: {
|
static fromPersistence(props: {
|
||||||
id: string;
|
id: string;
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
plan: string; // Accepts both old and new plan names
|
plan: SubscriptionPlanType;
|
||||||
status: SubscriptionStatusType;
|
status: SubscriptionStatusType;
|
||||||
stripeCustomerId: string | null;
|
stripeCustomerId: string | null;
|
||||||
stripeSubscriptionId: string | null;
|
stripeSubscriptionId: string | null;
|
||||||
@ -112,7 +84,7 @@ export class Subscription {
|
|||||||
return new Subscription({
|
return new Subscription({
|
||||||
id: props.id,
|
id: props.id,
|
||||||
organizationId: props.organizationId,
|
organizationId: props.organizationId,
|
||||||
plan: SubscriptionPlan.fromString(props.plan),
|
plan: SubscriptionPlan.create(props.plan),
|
||||||
status: SubscriptionStatus.create(props.status),
|
status: SubscriptionStatus.create(props.status),
|
||||||
stripeCustomerId: props.stripeCustomerId,
|
stripeCustomerId: props.stripeCustomerId,
|
||||||
stripeSubscriptionId: props.stripeSubscriptionId,
|
stripeSubscriptionId: props.stripeSubscriptionId,
|
||||||
@ -264,7 +236,7 @@ export class Subscription {
|
|||||||
this.props.plan.value,
|
this.props.plan.value,
|
||||||
newPlan.value,
|
newPlan.value,
|
||||||
currentUserCount,
|
currentUserCount,
|
||||||
newPlan.maxLicenses
|
newPlan.maxLicenses,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,17 +0,0 @@
|
|||||||
/**
|
|
||||||
* Shipment Limit Exceeded Exception
|
|
||||||
*
|
|
||||||
* Thrown when an organization has reached its annual shipment limit (Bronze plan).
|
|
||||||
*/
|
|
||||||
export class ShipmentLimitExceededException extends Error {
|
|
||||||
constructor(
|
|
||||||
public readonly organizationId: string,
|
|
||||||
public readonly currentCount: number,
|
|
||||||
public readonly maxCount: number
|
|
||||||
) {
|
|
||||||
super(
|
|
||||||
`L'organisation a atteint sa limite de ${maxCount} expéditions par an (${currentCount}/${maxCount}). Passez à un plan supérieur pour des expéditions illimitées.`
|
|
||||||
);
|
|
||||||
this.name = 'ShipmentLimitExceededException';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -6,11 +6,11 @@ export class NoLicensesAvailableException extends Error {
|
|||||||
constructor(
|
constructor(
|
||||||
public readonly organizationId: string,
|
public readonly organizationId: string,
|
||||||
public readonly currentLicenses: number,
|
public readonly currentLicenses: number,
|
||||||
public readonly maxLicenses: number
|
public readonly maxLicenses: number,
|
||||||
) {
|
) {
|
||||||
super(
|
super(
|
||||||
`No licenses available for organization ${organizationId}. ` +
|
`No licenses available for organization ${organizationId}. ` +
|
||||||
`Currently using ${currentLicenses}/${maxLicenses} licenses.`
|
`Currently using ${currentLicenses}/${maxLicenses} licenses.`,
|
||||||
);
|
);
|
||||||
this.name = 'NoLicensesAvailableException';
|
this.name = 'NoLicensesAvailableException';
|
||||||
Object.setPrototypeOf(this, NoLicensesAvailableException.prototype);
|
Object.setPrototypeOf(this, NoLicensesAvailableException.prototype);
|
||||||
@ -46,11 +46,11 @@ export class InvalidSubscriptionDowngradeException extends Error {
|
|||||||
public readonly currentPlan: string,
|
public readonly currentPlan: string,
|
||||||
public readonly targetPlan: string,
|
public readonly targetPlan: string,
|
||||||
public readonly currentUsers: number,
|
public readonly currentUsers: number,
|
||||||
public readonly targetMaxLicenses: number
|
public readonly targetMaxLicenses: number,
|
||||||
) {
|
) {
|
||||||
super(
|
super(
|
||||||
`Cannot downgrade from ${currentPlan} to ${targetPlan}. ` +
|
`Cannot downgrade from ${currentPlan} to ${targetPlan}. ` +
|
||||||
`Current users (${currentUsers}) exceed target plan limit (${targetMaxLicenses}).`
|
`Current users (${currentUsers}) exceed target plan limit (${targetMaxLicenses}).`,
|
||||||
);
|
);
|
||||||
this.name = 'InvalidSubscriptionDowngradeException';
|
this.name = 'InvalidSubscriptionDowngradeException';
|
||||||
Object.setPrototypeOf(this, InvalidSubscriptionDowngradeException.prototype);
|
Object.setPrototypeOf(this, InvalidSubscriptionDowngradeException.prototype);
|
||||||
@ -60,9 +60,11 @@ export class InvalidSubscriptionDowngradeException extends Error {
|
|||||||
export class SubscriptionNotActiveException extends Error {
|
export class SubscriptionNotActiveException extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
public readonly subscriptionId: string,
|
public readonly subscriptionId: string,
|
||||||
public readonly currentStatus: string
|
public readonly currentStatus: string,
|
||||||
) {
|
) {
|
||||||
super(`Subscription ${subscriptionId} is not active. Current status: ${currentStatus}`);
|
super(
|
||||||
|
`Subscription ${subscriptionId} is not active. Current status: ${currentStatus}`,
|
||||||
|
);
|
||||||
this.name = 'SubscriptionNotActiveException';
|
this.name = 'SubscriptionNotActiveException';
|
||||||
Object.setPrototypeOf(this, SubscriptionNotActiveException.prototype);
|
Object.setPrototypeOf(this, SubscriptionNotActiveException.prototype);
|
||||||
}
|
}
|
||||||
@ -71,10 +73,13 @@ export class SubscriptionNotActiveException extends Error {
|
|||||||
export class InvalidSubscriptionStatusTransitionException extends Error {
|
export class InvalidSubscriptionStatusTransitionException extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
public readonly fromStatus: string,
|
public readonly fromStatus: string,
|
||||||
public readonly toStatus: string
|
public readonly toStatus: string,
|
||||||
) {
|
) {
|
||||||
super(`Invalid subscription status transition from ${fromStatus} to ${toStatus}`);
|
super(`Invalid subscription status transition from ${fromStatus} to ${toStatus}`);
|
||||||
this.name = 'InvalidSubscriptionStatusTransitionException';
|
this.name = 'InvalidSubscriptionStatusTransitionException';
|
||||||
Object.setPrototypeOf(this, InvalidSubscriptionStatusTransitionException.prototype);
|
Object.setPrototypeOf(
|
||||||
|
this,
|
||||||
|
InvalidSubscriptionStatusTransitionException.prototype,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,15 +0,0 @@
|
|||||||
/**
|
|
||||||
* Shipment Counter Port
|
|
||||||
*
|
|
||||||
* Counts total shipments (bookings + CSV bookings) for an organization
|
|
||||||
* within a given year. Used to enforce the Bronze plan's 12 shipments/year limit.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export const SHIPMENT_COUNTER_PORT = 'SHIPMENT_COUNTER_PORT';
|
|
||||||
|
|
||||||
export interface ShipmentCounterPort {
|
|
||||||
/**
|
|
||||||
* Count all shipments (bookings + CSV bookings) created by an organization in a given year.
|
|
||||||
*/
|
|
||||||
countShipmentsForOrganizationInYear(organizationId: string, year: number): Promise<number>;
|
|
||||||
}
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
export const SIRET_VERIFICATION_PORT = 'SIRET_VERIFICATION_PORT';
|
|
||||||
|
|
||||||
export interface SiretVerificationResult {
|
|
||||||
valid: boolean;
|
|
||||||
companyName?: string;
|
|
||||||
address?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SiretVerificationPort {
|
|
||||||
verify(siret: string): Promise<SiretVerificationResult>;
|
|
||||||
}
|
|
||||||
@ -43,22 +43,6 @@ export interface StripeSubscriptionData {
|
|||||||
cancelAtPeriodEnd: boolean;
|
cancelAtPeriodEnd: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateCommissionCheckoutInput {
|
|
||||||
bookingId: string;
|
|
||||||
amountCents: number;
|
|
||||||
currency: 'eur';
|
|
||||||
customerEmail: string;
|
|
||||||
organizationId: string;
|
|
||||||
bookingDescription: string;
|
|
||||||
successUrl: string;
|
|
||||||
cancelUrl: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateCommissionCheckoutOutput {
|
|
||||||
sessionId: string;
|
|
||||||
sessionUrl: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StripeCheckoutSessionData {
|
export interface StripeCheckoutSessionData {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
customerId: string | null;
|
customerId: string | null;
|
||||||
@ -78,19 +62,16 @@ export interface StripePort {
|
|||||||
/**
|
/**
|
||||||
* Create a Stripe Checkout session for subscription purchase
|
* Create a Stripe Checkout session for subscription purchase
|
||||||
*/
|
*/
|
||||||
createCheckoutSession(input: CreateCheckoutSessionInput): Promise<CreateCheckoutSessionOutput>;
|
createCheckoutSession(
|
||||||
|
input: CreateCheckoutSessionInput,
|
||||||
/**
|
): Promise<CreateCheckoutSessionOutput>;
|
||||||
* Create a Stripe Checkout session for one-time commission payment
|
|
||||||
*/
|
|
||||||
createCommissionCheckout(
|
|
||||||
input: CreateCommissionCheckoutInput
|
|
||||||
): Promise<CreateCommissionCheckoutOutput>;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a Stripe Customer Portal session for subscription management
|
* Create a Stripe Customer Portal session for subscription management
|
||||||
*/
|
*/
|
||||||
createPortalSession(input: CreatePortalSessionInput): Promise<CreatePortalSessionOutput>;
|
createPortalSession(
|
||||||
|
input: CreatePortalSessionInput,
|
||||||
|
): Promise<CreatePortalSessionOutput>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieve subscription details from Stripe
|
* Retrieve subscription details from Stripe
|
||||||
@ -120,7 +101,10 @@ export interface StripePort {
|
|||||||
/**
|
/**
|
||||||
* Verify and parse a Stripe webhook event
|
* Verify and parse a Stripe webhook event
|
||||||
*/
|
*/
|
||||||
constructWebhookEvent(payload: string | Buffer, signature: string): Promise<StripeWebhookEvent>;
|
constructWebhookEvent(
|
||||||
|
payload: string | Buffer,
|
||||||
|
signature: string,
|
||||||
|
): Promise<StripeWebhookEvent>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Map a Stripe price ID to a subscription plan
|
* Map a Stripe price ID to a subscription plan
|
||||||
|
|||||||
@ -1,53 +0,0 @@
|
|||||||
/**
|
|
||||||
* Plan Feature Value Object
|
|
||||||
*
|
|
||||||
* Defines the features available per subscription plan.
|
|
||||||
* Used by the FeatureFlagGuard to enforce access control.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export type PlanFeature =
|
|
||||||
| 'dashboard'
|
|
||||||
| 'wiki'
|
|
||||||
| 'user_management'
|
|
||||||
| 'csv_export'
|
|
||||||
| 'api_access'
|
|
||||||
| 'custom_interface'
|
|
||||||
| 'dedicated_kam';
|
|
||||||
|
|
||||||
export const ALL_PLAN_FEATURES: readonly PlanFeature[] = [
|
|
||||||
'dashboard',
|
|
||||||
'wiki',
|
|
||||||
'user_management',
|
|
||||||
'csv_export',
|
|
||||||
'api_access',
|
|
||||||
'custom_interface',
|
|
||||||
'dedicated_kam',
|
|
||||||
];
|
|
||||||
|
|
||||||
export type SubscriptionPlanTypeForFeatures = 'BRONZE' | 'SILVER' | 'GOLD' | 'PLATINIUM';
|
|
||||||
|
|
||||||
export const PLAN_FEATURES: Record<SubscriptionPlanTypeForFeatures, readonly PlanFeature[]> = {
|
|
||||||
BRONZE: [],
|
|
||||||
SILVER: ['dashboard', 'wiki', 'user_management', 'csv_export'],
|
|
||||||
GOLD: ['dashboard', 'wiki', 'user_management', 'csv_export', 'api_access'],
|
|
||||||
PLATINIUM: [
|
|
||||||
'dashboard',
|
|
||||||
'wiki',
|
|
||||||
'user_management',
|
|
||||||
'csv_export',
|
|
||||||
'api_access',
|
|
||||||
'custom_interface',
|
|
||||||
'dedicated_kam',
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
export function planHasFeature(
|
|
||||||
plan: SubscriptionPlanTypeForFeatures,
|
|
||||||
feature: PlanFeature
|
|
||||||
): boolean {
|
|
||||||
return PLAN_FEATURES[plan].includes(feature);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function planGetFeatures(plan: SubscriptionPlanTypeForFeatures): readonly PlanFeature[] {
|
|
||||||
return PLAN_FEATURES[plan];
|
|
||||||
}
|
|
||||||
@ -2,109 +2,68 @@
|
|||||||
* Subscription Plan Value Object
|
* Subscription Plan Value Object
|
||||||
*
|
*
|
||||||
* Represents the different subscription plans available for organizations.
|
* Represents the different subscription plans available for organizations.
|
||||||
* Each plan has a maximum number of licenses, shipment limits, commission rates,
|
* Each plan has a maximum number of licenses that determine how many users
|
||||||
* feature flags, and support levels.
|
* can be active in an organization.
|
||||||
*
|
|
||||||
* Plans: BRONZE (free), SILVER (249EUR/mo), GOLD (899EUR/mo), PLATINIUM (custom)
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { PlanFeature, PLAN_FEATURES } from './plan-feature.vo';
|
export type SubscriptionPlanType = 'FREE' | 'STARTER' | 'PRO' | 'ENTERPRISE';
|
||||||
|
|
||||||
export type SubscriptionPlanType = 'BRONZE' | 'SILVER' | 'GOLD' | 'PLATINIUM';
|
|
||||||
|
|
||||||
export type SupportLevel = 'none' | 'email' | 'direct' | 'dedicated_kam';
|
|
||||||
export type StatusBadge = 'none' | 'silver' | 'gold' | 'platinium';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Legacy plan name mapping for backward compatibility during migration.
|
|
||||||
*/
|
|
||||||
const LEGACY_PLAN_MAPPING: Record<string, SubscriptionPlanType> = {
|
|
||||||
FREE: 'BRONZE',
|
|
||||||
STARTER: 'SILVER',
|
|
||||||
PRO: 'GOLD',
|
|
||||||
ENTERPRISE: 'PLATINIUM',
|
|
||||||
};
|
|
||||||
|
|
||||||
interface PlanDetails {
|
interface PlanDetails {
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
readonly maxLicenses: number; // -1 means unlimited
|
readonly maxLicenses: number; // -1 means unlimited
|
||||||
readonly monthlyPriceEur: number;
|
readonly monthlyPriceEur: number;
|
||||||
readonly yearlyPriceEur: number;
|
readonly yearlyPriceEur: number;
|
||||||
readonly maxShipmentsPerYear: number; // -1 means unlimited
|
readonly features: readonly string[];
|
||||||
readonly commissionRatePercent: number;
|
|
||||||
readonly statusBadge: StatusBadge;
|
|
||||||
readonly supportLevel: SupportLevel;
|
|
||||||
readonly planFeatures: readonly PlanFeature[];
|
|
||||||
readonly features: readonly string[]; // Human-readable feature descriptions
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const PLAN_DETAILS: Record<SubscriptionPlanType, PlanDetails> = {
|
const PLAN_DETAILS: Record<SubscriptionPlanType, PlanDetails> = {
|
||||||
BRONZE: {
|
FREE: {
|
||||||
name: 'Bronze',
|
name: 'Free',
|
||||||
maxLicenses: 1,
|
maxLicenses: 2,
|
||||||
monthlyPriceEur: 0,
|
monthlyPriceEur: 0,
|
||||||
yearlyPriceEur: 0,
|
yearlyPriceEur: 0,
|
||||||
maxShipmentsPerYear: 12,
|
features: [
|
||||||
commissionRatePercent: 5,
|
'Up to 2 users',
|
||||||
statusBadge: 'none',
|
'Basic rate search',
|
||||||
supportLevel: 'none',
|
'Email support',
|
||||||
planFeatures: PLAN_FEATURES.BRONZE,
|
],
|
||||||
features: ['1 utilisateur', '12 expéditions par an', 'Recherche de tarifs basique'],
|
|
||||||
},
|
},
|
||||||
SILVER: {
|
STARTER: {
|
||||||
name: 'Silver',
|
name: 'Starter',
|
||||||
maxLicenses: 5,
|
maxLicenses: 5,
|
||||||
monthlyPriceEur: 249,
|
monthlyPriceEur: 49,
|
||||||
yearlyPriceEur: 2739, // 249 * 11 months
|
yearlyPriceEur: 470, // ~20% discount
|
||||||
maxShipmentsPerYear: -1,
|
|
||||||
commissionRatePercent: 3,
|
|
||||||
statusBadge: 'silver',
|
|
||||||
supportLevel: 'email',
|
|
||||||
planFeatures: PLAN_FEATURES.SILVER,
|
|
||||||
features: [
|
features: [
|
||||||
"Jusqu'à 5 utilisateurs",
|
'Up to 5 users',
|
||||||
'Expéditions illimitées',
|
'Advanced rate search',
|
||||||
'Tableau de bord',
|
'CSV imports',
|
||||||
'Wiki Maritime',
|
'Priority email support',
|
||||||
'Gestion des utilisateurs',
|
|
||||||
'Import CSV',
|
|
||||||
'Support par email',
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
GOLD: {
|
PRO: {
|
||||||
name: 'Gold',
|
name: 'Pro',
|
||||||
maxLicenses: 20,
|
maxLicenses: 20,
|
||||||
monthlyPriceEur: 899,
|
monthlyPriceEur: 149,
|
||||||
yearlyPriceEur: 9889, // 899 * 11 months
|
yearlyPriceEur: 1430, // ~20% discount
|
||||||
maxShipmentsPerYear: -1,
|
|
||||||
commissionRatePercent: 2,
|
|
||||||
statusBadge: 'gold',
|
|
||||||
supportLevel: 'direct',
|
|
||||||
planFeatures: PLAN_FEATURES.GOLD,
|
|
||||||
features: [
|
features: [
|
||||||
"Jusqu'à 20 utilisateurs",
|
'Up to 20 users',
|
||||||
'Expéditions illimitées',
|
'All Starter features',
|
||||||
'Toutes les fonctionnalités Silver',
|
'API access',
|
||||||
'Intégration API',
|
'Custom integrations',
|
||||||
'Assistance commerciale directe',
|
'Phone support',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
PLATINIUM: {
|
ENTERPRISE: {
|
||||||
name: 'Platinium',
|
name: 'Enterprise',
|
||||||
maxLicenses: -1, // unlimited
|
maxLicenses: -1, // unlimited
|
||||||
monthlyPriceEur: 0, // custom pricing
|
monthlyPriceEur: 0, // custom pricing
|
||||||
yearlyPriceEur: 0, // custom pricing
|
yearlyPriceEur: 0, // custom pricing
|
||||||
maxShipmentsPerYear: -1,
|
|
||||||
commissionRatePercent: 1,
|
|
||||||
statusBadge: 'platinium',
|
|
||||||
supportLevel: 'dedicated_kam',
|
|
||||||
planFeatures: PLAN_FEATURES.PLATINIUM,
|
|
||||||
features: [
|
features: [
|
||||||
'Utilisateurs illimités',
|
'Unlimited users',
|
||||||
'Toutes les fonctionnalités Gold',
|
'All Pro features',
|
||||||
'Key Account Manager dédié',
|
'Dedicated account manager',
|
||||||
'Interface personnalisable',
|
'Custom SLA',
|
||||||
'Contrats tarifaires cadre',
|
'On-premise deployment option',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@ -119,68 +78,36 @@ export class SubscriptionPlan {
|
|||||||
return new SubscriptionPlan(plan);
|
return new SubscriptionPlan(plan);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Create from string with legacy name support.
|
|
||||||
* Accepts both old (FREE/STARTER/PRO/ENTERPRISE) and new (BRONZE/SILVER/GOLD/PLATINIUM) names.
|
|
||||||
*/
|
|
||||||
static fromString(value: string): SubscriptionPlan {
|
static fromString(value: string): SubscriptionPlan {
|
||||||
const upperValue = value.toUpperCase();
|
const upperValue = value.toUpperCase() as SubscriptionPlanType;
|
||||||
|
if (!PLAN_DETAILS[upperValue]) {
|
||||||
// Check legacy mapping first
|
throw new Error(`Invalid subscription plan: ${value}`);
|
||||||
const mapped = LEGACY_PLAN_MAPPING[upperValue];
|
|
||||||
if (mapped) {
|
|
||||||
return new SubscriptionPlan(mapped);
|
|
||||||
}
|
}
|
||||||
|
return new SubscriptionPlan(upperValue);
|
||||||
// Try direct match
|
|
||||||
if (PLAN_DETAILS[upperValue as SubscriptionPlanType]) {
|
|
||||||
return new SubscriptionPlan(upperValue as SubscriptionPlanType);
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(`Invalid subscription plan: ${value}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Named factories
|
|
||||||
static bronze(): SubscriptionPlan {
|
|
||||||
return new SubscriptionPlan('BRONZE');
|
|
||||||
}
|
|
||||||
|
|
||||||
static silver(): SubscriptionPlan {
|
|
||||||
return new SubscriptionPlan('SILVER');
|
|
||||||
}
|
|
||||||
|
|
||||||
static gold(): SubscriptionPlan {
|
|
||||||
return new SubscriptionPlan('GOLD');
|
|
||||||
}
|
|
||||||
|
|
||||||
static platinium(): SubscriptionPlan {
|
|
||||||
return new SubscriptionPlan('PLATINIUM');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Legacy aliases
|
|
||||||
static free(): SubscriptionPlan {
|
static free(): SubscriptionPlan {
|
||||||
return SubscriptionPlan.bronze();
|
return new SubscriptionPlan('FREE');
|
||||||
}
|
}
|
||||||
|
|
||||||
static starter(): SubscriptionPlan {
|
static starter(): SubscriptionPlan {
|
||||||
return SubscriptionPlan.silver();
|
return new SubscriptionPlan('STARTER');
|
||||||
}
|
}
|
||||||
|
|
||||||
static pro(): SubscriptionPlan {
|
static pro(): SubscriptionPlan {
|
||||||
return SubscriptionPlan.gold();
|
return new SubscriptionPlan('PRO');
|
||||||
}
|
}
|
||||||
|
|
||||||
static enterprise(): SubscriptionPlan {
|
static enterprise(): SubscriptionPlan {
|
||||||
return SubscriptionPlan.platinium();
|
return new SubscriptionPlan('ENTERPRISE');
|
||||||
}
|
}
|
||||||
|
|
||||||
static getAllPlans(): SubscriptionPlan[] {
|
static getAllPlans(): SubscriptionPlan[] {
|
||||||
return (['BRONZE', 'SILVER', 'GOLD', 'PLATINIUM'] as SubscriptionPlanType[]).map(
|
return ['FREE', 'STARTER', 'PRO', 'ENTERPRISE'].map(
|
||||||
p => new SubscriptionPlan(p)
|
(p) => new SubscriptionPlan(p as SubscriptionPlanType),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Getters
|
|
||||||
get value(): SubscriptionPlanType {
|
get value(): SubscriptionPlanType {
|
||||||
return this.plan;
|
return this.plan;
|
||||||
}
|
}
|
||||||
@ -205,33 +132,6 @@ export class SubscriptionPlan {
|
|||||||
return PLAN_DETAILS[this.plan].features;
|
return PLAN_DETAILS[this.plan].features;
|
||||||
}
|
}
|
||||||
|
|
||||||
get maxShipmentsPerYear(): number {
|
|
||||||
return PLAN_DETAILS[this.plan].maxShipmentsPerYear;
|
|
||||||
}
|
|
||||||
|
|
||||||
get commissionRatePercent(): number {
|
|
||||||
return PLAN_DETAILS[this.plan].commissionRatePercent;
|
|
||||||
}
|
|
||||||
|
|
||||||
get statusBadge(): StatusBadge {
|
|
||||||
return PLAN_DETAILS[this.plan].statusBadge;
|
|
||||||
}
|
|
||||||
|
|
||||||
get supportLevel(): SupportLevel {
|
|
||||||
return PLAN_DETAILS[this.plan].supportLevel;
|
|
||||||
}
|
|
||||||
|
|
||||||
get planFeatures(): readonly PlanFeature[] {
|
|
||||||
return PLAN_DETAILS[this.plan].planFeatures;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if this plan includes a specific feature
|
|
||||||
*/
|
|
||||||
hasFeature(feature: PlanFeature): boolean {
|
|
||||||
return this.planFeatures.includes(feature);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns true if this plan has unlimited licenses
|
* Returns true if this plan has unlimited licenses
|
||||||
*/
|
*/
|
||||||
@ -239,32 +139,18 @@ export class SubscriptionPlan {
|
|||||||
return this.maxLicenses === -1;
|
return this.maxLicenses === -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns true if this plan has unlimited shipments
|
|
||||||
*/
|
|
||||||
hasUnlimitedShipments(): boolean {
|
|
||||||
return this.maxShipmentsPerYear === -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns true if this is a paid plan
|
* Returns true if this is a paid plan
|
||||||
*/
|
*/
|
||||||
isPaid(): boolean {
|
isPaid(): boolean {
|
||||||
return this.plan !== 'BRONZE';
|
return this.plan !== 'FREE';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns true if this is the free (Bronze) plan
|
* Returns true if this is the free plan
|
||||||
*/
|
*/
|
||||||
isFree(): boolean {
|
isFree(): boolean {
|
||||||
return this.plan === 'BRONZE';
|
return this.plan === 'FREE';
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns true if this plan has custom pricing (Platinium)
|
|
||||||
*/
|
|
||||||
isCustomPricing(): boolean {
|
|
||||||
return this.plan === 'PLATINIUM';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -279,7 +165,12 @@ export class SubscriptionPlan {
|
|||||||
* Check if upgrade to target plan is allowed
|
* Check if upgrade to target plan is allowed
|
||||||
*/
|
*/
|
||||||
canUpgradeTo(targetPlan: SubscriptionPlan): boolean {
|
canUpgradeTo(targetPlan: SubscriptionPlan): boolean {
|
||||||
const planOrder: SubscriptionPlanType[] = ['BRONZE', 'SILVER', 'GOLD', 'PLATINIUM'];
|
const planOrder: SubscriptionPlanType[] = [
|
||||||
|
'FREE',
|
||||||
|
'STARTER',
|
||||||
|
'PRO',
|
||||||
|
'ENTERPRISE',
|
||||||
|
];
|
||||||
const currentIndex = planOrder.indexOf(this.plan);
|
const currentIndex = planOrder.indexOf(this.plan);
|
||||||
const targetIndex = planOrder.indexOf(targetPlan.value);
|
const targetIndex = planOrder.indexOf(targetPlan.value);
|
||||||
return targetIndex > currentIndex;
|
return targetIndex > currentIndex;
|
||||||
@ -289,7 +180,12 @@ export class SubscriptionPlan {
|
|||||||
* Check if downgrade to target plan is allowed given current user count
|
* Check if downgrade to target plan is allowed given current user count
|
||||||
*/
|
*/
|
||||||
canDowngradeTo(targetPlan: SubscriptionPlan, currentUserCount: number): boolean {
|
canDowngradeTo(targetPlan: SubscriptionPlan, currentUserCount: number): boolean {
|
||||||
const planOrder: SubscriptionPlanType[] = ['BRONZE', 'SILVER', 'GOLD', 'PLATINIUM'];
|
const planOrder: SubscriptionPlanType[] = [
|
||||||
|
'FREE',
|
||||||
|
'STARTER',
|
||||||
|
'PRO',
|
||||||
|
'ENTERPRISE',
|
||||||
|
];
|
||||||
const currentIndex = planOrder.indexOf(this.plan);
|
const currentIndex = planOrder.indexOf(this.plan);
|
||||||
const targetIndex = planOrder.indexOf(targetPlan.value);
|
const targetIndex = planOrder.indexOf(targetPlan.value);
|
||||||
|
|
||||||
|
|||||||
@ -191,7 +191,9 @@ export class SubscriptionStatus {
|
|||||||
*/
|
*/
|
||||||
transitionTo(newStatus: SubscriptionStatus): SubscriptionStatus {
|
transitionTo(newStatus: SubscriptionStatus): SubscriptionStatus {
|
||||||
if (!this.canTransitionTo(newStatus)) {
|
if (!this.canTransitionTo(newStatus)) {
|
||||||
throw new Error(`Invalid status transition from ${this.status} to ${newStatus.value}`);
|
throw new Error(
|
||||||
|
`Invalid status transition from ${this.status} to ${newStatus.value}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return newStatus;
|
return newStatus;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -618,8 +618,6 @@ export class EmailAdapter implements EmailPort {
|
|||||||
html,
|
html,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(`New documents notification sent to ${carrierEmail} for booking ${data.bookingId}`);
|
||||||
`New documents notification sent to ${carrierEmail} for booking ${data.bookingId}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,50 +0,0 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import {
|
|
||||||
SiretVerificationPort,
|
|
||||||
SiretVerificationResult,
|
|
||||||
} from '@domain/ports/out/siret-verification.port';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class PappersSiretAdapter implements SiretVerificationPort {
|
|
||||||
private readonly logger = new Logger(PappersSiretAdapter.name);
|
|
||||||
private readonly apiKey: string;
|
|
||||||
private readonly baseUrl = 'https://api.pappers.fr/v2';
|
|
||||||
|
|
||||||
constructor(private readonly configService: ConfigService) {
|
|
||||||
this.apiKey = this.configService.get<string>('PAPPERS_API_KEY', '');
|
|
||||||
}
|
|
||||||
|
|
||||||
async verify(siret: string): Promise<SiretVerificationResult> {
|
|
||||||
if (!this.apiKey) {
|
|
||||||
this.logger.warn('PAPPERS_API_KEY not configured, skipping SIRET verification');
|
|
||||||
return { valid: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const url = `${this.baseUrl}/entreprise?api_token=${this.apiKey}&siret=${siret}`;
|
|
||||||
const response = await fetch(url);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
if (response.status === 404) {
|
|
||||||
return { valid: false };
|
|
||||||
}
|
|
||||||
this.logger.error(`Pappers API error: ${response.status} ${response.statusText}`);
|
|
||||||
return { valid: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
return {
|
|
||||||
valid: true,
|
|
||||||
companyName: data.nom_entreprise || data.denomination,
|
|
||||||
address: data.siege?.adresse_ligne_1
|
|
||||||
? `${data.siege.adresse_ligne_1}, ${data.siege.code_postal} ${data.siege.ville}`
|
|
||||||
: undefined,
|
|
||||||
};
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`SIRET verification failed: ${error?.message}`, error?.stack);
|
|
||||||
return { valid: false };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -92,18 +92,6 @@ export class BookingOrmEntity {
|
|||||||
@Column({ name: 'special_instructions', type: 'text', nullable: true })
|
@Column({ name: 'special_instructions', type: 'text', nullable: true })
|
||||||
specialInstructions: string | null;
|
specialInstructions: string | null;
|
||||||
|
|
||||||
@Column({ name: 'commission_rate', type: 'decimal', precision: 5, scale: 2, nullable: true })
|
|
||||||
commissionRate: number | null;
|
|
||||||
|
|
||||||
@Column({
|
|
||||||
name: 'commission_amount_eur',
|
|
||||||
type: 'decimal',
|
|
||||||
precision: 12,
|
|
||||||
scale: 2,
|
|
||||||
nullable: true,
|
|
||||||
})
|
|
||||||
commissionAmountEur: number | null;
|
|
||||||
|
|
||||||
@CreateDateColumn({ name: 'created_at' })
|
@CreateDateColumn({ name: 'created_at' })
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|
||||||
|
|||||||
@ -1,58 +1,58 @@
|
|||||||
/**
|
/**
|
||||||
* Cookie Consent ORM Entity (Infrastructure Layer)
|
* Cookie Consent ORM Entity (Infrastructure Layer)
|
||||||
*
|
*
|
||||||
* TypeORM entity for cookie consent persistence
|
* TypeORM entity for cookie consent persistence
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Entity,
|
Entity,
|
||||||
Column,
|
Column,
|
||||||
PrimaryColumn,
|
PrimaryColumn,
|
||||||
CreateDateColumn,
|
CreateDateColumn,
|
||||||
UpdateDateColumn,
|
UpdateDateColumn,
|
||||||
Index,
|
Index,
|
||||||
ManyToOne,
|
ManyToOne,
|
||||||
JoinColumn,
|
JoinColumn,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { UserOrmEntity } from './user.orm-entity';
|
import { UserOrmEntity } from './user.orm-entity';
|
||||||
|
|
||||||
@Entity('cookie_consents')
|
@Entity('cookie_consents')
|
||||||
@Index('idx_cookie_consents_user', ['userId'])
|
@Index('idx_cookie_consents_user', ['userId'])
|
||||||
export class CookieConsentOrmEntity {
|
export class CookieConsentOrmEntity {
|
||||||
@PrimaryColumn('uuid')
|
@PrimaryColumn('uuid')
|
||||||
id: string;
|
id: string;
|
||||||
|
|
||||||
@Column({ name: 'user_id', type: 'uuid', unique: true })
|
@Column({ name: 'user_id', type: 'uuid', unique: true })
|
||||||
userId: string;
|
userId: string;
|
||||||
|
|
||||||
@ManyToOne(() => UserOrmEntity, { onDelete: 'CASCADE' })
|
@ManyToOne(() => UserOrmEntity, { onDelete: 'CASCADE' })
|
||||||
@JoinColumn({ name: 'user_id' })
|
@JoinColumn({ name: 'user_id' })
|
||||||
user: UserOrmEntity;
|
user: UserOrmEntity;
|
||||||
|
|
||||||
@Column({ type: 'boolean', default: true })
|
@Column({ type: 'boolean', default: true })
|
||||||
essential: boolean;
|
essential: boolean;
|
||||||
|
|
||||||
@Column({ type: 'boolean', default: false })
|
@Column({ type: 'boolean', default: false })
|
||||||
functional: boolean;
|
functional: boolean;
|
||||||
|
|
||||||
@Column({ type: 'boolean', default: false })
|
@Column({ type: 'boolean', default: false })
|
||||||
analytics: boolean;
|
analytics: boolean;
|
||||||
|
|
||||||
@Column({ type: 'boolean', default: false })
|
@Column({ type: 'boolean', default: false })
|
||||||
marketing: boolean;
|
marketing: boolean;
|
||||||
|
|
||||||
@Column({ name: 'ip_address', type: 'varchar', length: 45, nullable: true })
|
@Column({ name: 'ip_address', type: 'varchar', length: 45, nullable: true })
|
||||||
ipAddress: string | null;
|
ipAddress: string | null;
|
||||||
|
|
||||||
@Column({ name: 'user_agent', type: 'text', nullable: true })
|
@Column({ name: 'user_agent', type: 'text', nullable: true })
|
||||||
userAgent: string | null;
|
userAgent: string | null;
|
||||||
|
|
||||||
@Column({ name: 'consent_date', type: 'timestamp', default: () => 'NOW()' })
|
@Column({ name: 'consent_date', type: 'timestamp', default: () => 'NOW()' })
|
||||||
consentDate: Date;
|
consentDate: Date;
|
||||||
|
|
||||||
@CreateDateColumn({ name: 'created_at' })
|
@CreateDateColumn({ name: 'created_at' })
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|
||||||
@UpdateDateColumn({ name: 'updated_at' })
|
@UpdateDateColumn({ name: 'updated_at' })
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -75,11 +75,11 @@ export class CsvBookingOrmEntity {
|
|||||||
@Column({
|
@Column({
|
||||||
name: 'status',
|
name: 'status',
|
||||||
type: 'enum',
|
type: 'enum',
|
||||||
enum: ['PENDING_PAYMENT', 'PENDING_BANK_TRANSFER', 'PENDING', 'ACCEPTED', 'REJECTED', 'CANCELLED'],
|
enum: ['PENDING', 'ACCEPTED', 'REJECTED', 'CANCELLED'],
|
||||||
default: 'PENDING_PAYMENT',
|
default: 'PENDING',
|
||||||
})
|
})
|
||||||
@Index()
|
@Index()
|
||||||
status: 'PENDING_PAYMENT' | 'PENDING_BANK_TRANSFER' | 'PENDING' | 'ACCEPTED' | 'REJECTED' | 'CANCELLED';
|
status: 'PENDING' | 'ACCEPTED' | 'REJECTED' | 'CANCELLED';
|
||||||
|
|
||||||
@Column({ name: 'documents', type: 'jsonb' })
|
@Column({ name: 'documents', type: 'jsonb' })
|
||||||
documents: Array<{
|
documents: Array<{
|
||||||
@ -141,21 +141,6 @@ export class CsvBookingOrmEntity {
|
|||||||
@Column({ name: 'carrier_notes', type: 'text', nullable: true })
|
@Column({ name: 'carrier_notes', type: 'text', nullable: true })
|
||||||
carrierNotes: string | null;
|
carrierNotes: string | null;
|
||||||
|
|
||||||
@Column({ name: 'stripe_payment_intent_id', type: 'varchar', length: 255, nullable: true })
|
|
||||||
stripePaymentIntentId: string | null;
|
|
||||||
|
|
||||||
@Column({ name: 'commission_rate', type: 'decimal', precision: 5, scale: 2, nullable: true })
|
|
||||||
commissionRate: number | null;
|
|
||||||
|
|
||||||
@Column({
|
|
||||||
name: 'commission_amount_eur',
|
|
||||||
type: 'decimal',
|
|
||||||
precision: 12,
|
|
||||||
scale: 2,
|
|
||||||
nullable: true,
|
|
||||||
})
|
|
||||||
commissionAmountEur: number | null;
|
|
||||||
|
|
||||||
@CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone' })
|
@CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone' })
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,14 @@
|
|||||||
* Represents user licenses linked to subscriptions.
|
* Represents user licenses linked to subscriptions.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Entity, Column, PrimaryGeneratedColumn, ManyToOne, JoinColumn, Index } from 'typeorm';
|
import {
|
||||||
|
Entity,
|
||||||
|
Column,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
Index,
|
||||||
|
} from 'typeorm';
|
||||||
import { SubscriptionOrmEntity } from './subscription.orm-entity';
|
import { SubscriptionOrmEntity } from './subscription.orm-entity';
|
||||||
import { UserOrmEntity } from './user.orm-entity';
|
import { UserOrmEntity } from './user.orm-entity';
|
||||||
|
|
||||||
@ -23,7 +30,7 @@ export class LicenseOrmEntity {
|
|||||||
@Column({ name: 'subscription_id', type: 'uuid' })
|
@Column({ name: 'subscription_id', type: 'uuid' })
|
||||||
subscriptionId: string;
|
subscriptionId: string;
|
||||||
|
|
||||||
@ManyToOne(() => SubscriptionOrmEntity, subscription => subscription.licenses, {
|
@ManyToOne(() => SubscriptionOrmEntity, (subscription) => subscription.licenses, {
|
||||||
onDelete: 'CASCADE',
|
onDelete: 'CASCADE',
|
||||||
})
|
})
|
||||||
@JoinColumn({ name: 'subscription_id' })
|
@JoinColumn({ name: 'subscription_id' })
|
||||||
|
|||||||
@ -56,15 +56,6 @@ export class OrganizationOrmEntity {
|
|||||||
@Column({ type: 'jsonb', default: '[]' })
|
@Column({ type: 'jsonb', default: '[]' })
|
||||||
documents: any[];
|
documents: any[];
|
||||||
|
|
||||||
@Column({ type: 'varchar', length: 14, nullable: true })
|
|
||||||
siret: string | null;
|
|
||||||
|
|
||||||
@Column({ name: 'siret_verified', type: 'boolean', default: false })
|
|
||||||
siretVerified: boolean;
|
|
||||||
|
|
||||||
@Column({ name: 'status_badge', type: 'varchar', length: 20, default: 'none' })
|
|
||||||
statusBadge: string;
|
|
||||||
|
|
||||||
@Column({ name: 'is_carrier', type: 'boolean', default: false })
|
@Column({ name: 'is_carrier', type: 'boolean', default: false })
|
||||||
isCarrier: boolean;
|
isCarrier: boolean;
|
||||||
|
|
||||||
|
|||||||
@ -19,7 +19,7 @@ import {
|
|||||||
import { OrganizationOrmEntity } from './organization.orm-entity';
|
import { OrganizationOrmEntity } from './organization.orm-entity';
|
||||||
import { LicenseOrmEntity } from './license.orm-entity';
|
import { LicenseOrmEntity } from './license.orm-entity';
|
||||||
|
|
||||||
export type SubscriptionPlanOrmType = 'BRONZE' | 'SILVER' | 'GOLD' | 'PLATINIUM';
|
export type SubscriptionPlanOrmType = 'FREE' | 'STARTER' | 'PRO' | 'ENTERPRISE';
|
||||||
|
|
||||||
export type SubscriptionStatusOrmType =
|
export type SubscriptionStatusOrmType =
|
||||||
| 'ACTIVE'
|
| 'ACTIVE'
|
||||||
@ -51,8 +51,8 @@ export class SubscriptionOrmEntity {
|
|||||||
// Plan information
|
// Plan information
|
||||||
@Column({
|
@Column({
|
||||||
type: 'enum',
|
type: 'enum',
|
||||||
enum: ['BRONZE', 'SILVER', 'GOLD', 'PLATINIUM'],
|
enum: ['FREE', 'STARTER', 'PRO', 'ENTERPRISE'],
|
||||||
default: 'BRONZE',
|
default: 'FREE',
|
||||||
})
|
})
|
||||||
plan: SubscriptionPlanOrmType;
|
plan: SubscriptionPlanOrmType;
|
||||||
|
|
||||||
@ -103,6 +103,6 @@ export class SubscriptionOrmEntity {
|
|||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
@OneToMany(() => LicenseOrmEntity, license => license.subscription)
|
@OneToMany(() => LicenseOrmEntity, (license) => license.subscription)
|
||||||
licenses: LicenseOrmEntity[];
|
licenses: LicenseOrmEntity[];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -27,8 +27,6 @@ export class BookingOrmMapper {
|
|||||||
orm.consignee = this.partyToJson(domain.consignee);
|
orm.consignee = this.partyToJson(domain.consignee);
|
||||||
orm.cargoDescription = domain.cargoDescription;
|
orm.cargoDescription = domain.cargoDescription;
|
||||||
orm.specialInstructions = domain.specialInstructions || null;
|
orm.specialInstructions = domain.specialInstructions || null;
|
||||||
orm.commissionRate = domain.commissionRate ?? null;
|
|
||||||
orm.commissionAmountEur = domain.commissionAmountEur ?? null;
|
|
||||||
orm.createdAt = domain.createdAt;
|
orm.createdAt = domain.createdAt;
|
||||||
orm.updatedAt = domain.updatedAt;
|
orm.updatedAt = domain.updatedAt;
|
||||||
|
|
||||||
@ -54,9 +52,6 @@ export class BookingOrmMapper {
|
|||||||
cargoDescription: orm.cargoDescription,
|
cargoDescription: orm.cargoDescription,
|
||||||
containers: orm.containers ? orm.containers.map(c => this.ormToContainer(c)) : [],
|
containers: orm.containers ? orm.containers.map(c => this.ormToContainer(c)) : [],
|
||||||
specialInstructions: orm.specialInstructions || undefined,
|
specialInstructions: orm.specialInstructions || undefined,
|
||||||
commissionRate: orm.commissionRate != null ? Number(orm.commissionRate) : undefined,
|
|
||||||
commissionAmountEur:
|
|
||||||
orm.commissionAmountEur != null ? Number(orm.commissionAmountEur) : undefined,
|
|
||||||
createdAt: orm.createdAt,
|
createdAt: orm.createdAt,
|
||||||
updatedAt: orm.updatedAt,
|
updatedAt: orm.updatedAt,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -42,10 +42,7 @@ export class CsvBookingMapper {
|
|||||||
ormEntity.respondedAt,
|
ormEntity.respondedAt,
|
||||||
ormEntity.notes,
|
ormEntity.notes,
|
||||||
ormEntity.rejectionReason,
|
ormEntity.rejectionReason,
|
||||||
ormEntity.bookingNumber ?? undefined,
|
ormEntity.bookingNumber ?? undefined
|
||||||
ormEntity.commissionRate != null ? Number(ormEntity.commissionRate) : undefined,
|
|
||||||
ormEntity.commissionAmountEur != null ? Number(ormEntity.commissionAmountEur) : undefined,
|
|
||||||
ormEntity.stripePaymentIntentId ?? undefined
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -69,16 +66,13 @@ export class CsvBookingMapper {
|
|||||||
primaryCurrency: domain.primaryCurrency,
|
primaryCurrency: domain.primaryCurrency,
|
||||||
transitDays: domain.transitDays,
|
transitDays: domain.transitDays,
|
||||||
containerType: domain.containerType,
|
containerType: domain.containerType,
|
||||||
status: domain.status as CsvBookingOrmEntity['status'],
|
status: domain.status as 'PENDING' | 'ACCEPTED' | 'REJECTED' | 'CANCELLED',
|
||||||
documents: domain.documents as any,
|
documents: domain.documents as any,
|
||||||
confirmationToken: domain.confirmationToken,
|
confirmationToken: domain.confirmationToken,
|
||||||
requestedAt: domain.requestedAt,
|
requestedAt: domain.requestedAt,
|
||||||
respondedAt: domain.respondedAt,
|
respondedAt: domain.respondedAt,
|
||||||
notes: domain.notes,
|
notes: domain.notes,
|
||||||
rejectionReason: domain.rejectionReason,
|
rejectionReason: domain.rejectionReason,
|
||||||
stripePaymentIntentId: domain.stripePaymentIntentId ?? null,
|
|
||||||
commissionRate: domain.commissionRate ?? null,
|
|
||||||
commissionAmountEur: domain.commissionAmountEur ?? null,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -87,13 +81,10 @@ export class CsvBookingMapper {
|
|||||||
*/
|
*/
|
||||||
static toOrmUpdate(domain: CsvBooking): Partial<CsvBookingOrmEntity> {
|
static toOrmUpdate(domain: CsvBooking): Partial<CsvBookingOrmEntity> {
|
||||||
return {
|
return {
|
||||||
status: domain.status as CsvBookingOrmEntity['status'],
|
status: domain.status as 'PENDING' | 'ACCEPTED' | 'REJECTED' | 'CANCELLED',
|
||||||
respondedAt: domain.respondedAt,
|
respondedAt: domain.respondedAt,
|
||||||
notes: domain.notes,
|
notes: domain.notes,
|
||||||
rejectionReason: domain.rejectionReason,
|
rejectionReason: domain.rejectionReason,
|
||||||
stripePaymentIntentId: domain.stripePaymentIntentId ?? null,
|
|
||||||
commissionRate: domain.commissionRate ?? null,
|
|
||||||
commissionAmountEur: domain.commissionAmountEur ?? null,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -30,9 +30,6 @@ export class OrganizationOrmMapper {
|
|||||||
orm.addressCountry = props.address.country;
|
orm.addressCountry = props.address.country;
|
||||||
orm.logoUrl = props.logoUrl || null;
|
orm.logoUrl = props.logoUrl || null;
|
||||||
orm.documents = props.documents;
|
orm.documents = props.documents;
|
||||||
orm.siret = props.siret || null;
|
|
||||||
orm.siretVerified = props.siretVerified;
|
|
||||||
orm.statusBadge = props.statusBadge;
|
|
||||||
orm.isActive = props.isActive;
|
orm.isActive = props.isActive;
|
||||||
orm.createdAt = props.createdAt;
|
orm.createdAt = props.createdAt;
|
||||||
orm.updatedAt = props.updatedAt;
|
orm.updatedAt = props.updatedAt;
|
||||||
@ -62,9 +59,6 @@ export class OrganizationOrmMapper {
|
|||||||
},
|
},
|
||||||
logoUrl: orm.logoUrl || undefined,
|
logoUrl: orm.logoUrl || undefined,
|
||||||
documents: orm.documents || [],
|
documents: orm.documents || [],
|
||||||
siret: orm.siret || undefined,
|
|
||||||
siretVerified: orm.siretVerified ?? false,
|
|
||||||
statusBadge: (orm.statusBadge as 'none' | 'silver' | 'gold' | 'platinium') || 'none',
|
|
||||||
isActive: orm.isActive,
|
isActive: orm.isActive,
|
||||||
createdAt: orm.createdAt,
|
createdAt: orm.createdAt,
|
||||||
updatedAt: orm.updatedAt,
|
updatedAt: orm.updatedAt,
|
||||||
|
|||||||
@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,62 +1,62 @@
|
|||||||
/**
|
/**
|
||||||
* Migration: Create Cookie Consents Table
|
* Migration: Create Cookie Consents Table
|
||||||
* GDPR compliant cookie preference storage
|
* GDPR compliant cookie preference storage
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
export class CreateCookieConsent1738100000000 implements MigrationInterface {
|
export class CreateCookieConsent1738100000000 implements MigrationInterface {
|
||||||
name = 'CreateCookieConsent1738100000000';
|
name = 'CreateCookieConsent1738100000000';
|
||||||
|
|
||||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
// Create cookie_consents table
|
// Create cookie_consents table
|
||||||
await queryRunner.query(`
|
await queryRunner.query(`
|
||||||
CREATE TABLE "cookie_consents" (
|
CREATE TABLE "cookie_consents" (
|
||||||
"id" UUID NOT NULL DEFAULT uuid_generate_v4(),
|
"id" UUID NOT NULL DEFAULT uuid_generate_v4(),
|
||||||
"user_id" UUID NOT NULL,
|
"user_id" UUID NOT NULL,
|
||||||
"essential" BOOLEAN NOT NULL DEFAULT TRUE,
|
"essential" BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
"functional" BOOLEAN NOT NULL DEFAULT FALSE,
|
"functional" BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
"analytics" BOOLEAN NOT NULL DEFAULT FALSE,
|
"analytics" BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
"marketing" BOOLEAN NOT NULL DEFAULT FALSE,
|
"marketing" BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
"ip_address" VARCHAR(45) NULL,
|
"ip_address" VARCHAR(45) NULL,
|
||||||
"user_agent" TEXT NULL,
|
"user_agent" TEXT NULL,
|
||||||
"consent_date" TIMESTAMP NOT NULL DEFAULT NOW(),
|
"consent_date" TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||||
"created_at" TIMESTAMP NOT NULL DEFAULT NOW(),
|
"created_at" TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||||
"updated_at" TIMESTAMP NOT NULL DEFAULT NOW(),
|
"updated_at" TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||||
CONSTRAINT "pk_cookie_consents" PRIMARY KEY ("id"),
|
CONSTRAINT "pk_cookie_consents" PRIMARY KEY ("id"),
|
||||||
CONSTRAINT "uq_cookie_consents_user" UNIQUE ("user_id"),
|
CONSTRAINT "uq_cookie_consents_user" UNIQUE ("user_id"),
|
||||||
CONSTRAINT "fk_cookie_consents_user" FOREIGN KEY ("user_id")
|
CONSTRAINT "fk_cookie_consents_user" FOREIGN KEY ("user_id")
|
||||||
REFERENCES "users"("id") ON DELETE CASCADE
|
REFERENCES "users"("id") ON DELETE CASCADE
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Create index for fast user lookups
|
// Create index for fast user lookups
|
||||||
await queryRunner.query(`
|
await queryRunner.query(`
|
||||||
CREATE INDEX "idx_cookie_consents_user" ON "cookie_consents" ("user_id")
|
CREATE INDEX "idx_cookie_consents_user" ON "cookie_consents" ("user_id")
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Add comments
|
// Add comments
|
||||||
await queryRunner.query(`
|
await queryRunner.query(`
|
||||||
COMMENT ON TABLE "cookie_consents" IS 'GDPR compliant cookie consent preferences per user'
|
COMMENT ON TABLE "cookie_consents" IS 'GDPR compliant cookie consent preferences per user'
|
||||||
`);
|
`);
|
||||||
await queryRunner.query(`
|
await queryRunner.query(`
|
||||||
COMMENT ON COLUMN "cookie_consents"."essential" IS 'Essential cookies - always true, required for functionality'
|
COMMENT ON COLUMN "cookie_consents"."essential" IS 'Essential cookies - always true, required for functionality'
|
||||||
`);
|
`);
|
||||||
await queryRunner.query(`
|
await queryRunner.query(`
|
||||||
COMMENT ON COLUMN "cookie_consents"."functional" IS 'Functional cookies - preferences, language, etc.'
|
COMMENT ON COLUMN "cookie_consents"."functional" IS 'Functional cookies - preferences, language, etc.'
|
||||||
`);
|
`);
|
||||||
await queryRunner.query(`
|
await queryRunner.query(`
|
||||||
COMMENT ON COLUMN "cookie_consents"."analytics" IS 'Analytics cookies - Google Analytics, Sentry, etc.'
|
COMMENT ON COLUMN "cookie_consents"."analytics" IS 'Analytics cookies - Google Analytics, Sentry, etc.'
|
||||||
`);
|
`);
|
||||||
await queryRunner.query(`
|
await queryRunner.query(`
|
||||||
COMMENT ON COLUMN "cookie_consents"."marketing" IS 'Marketing cookies - ads, tracking, remarketing'
|
COMMENT ON COLUMN "cookie_consents"."marketing" IS 'Marketing cookies - ads, tracking, remarketing'
|
||||||
`);
|
`);
|
||||||
await queryRunner.query(`
|
await queryRunner.query(`
|
||||||
COMMENT ON COLUMN "cookie_consents"."ip_address" IS 'IP address at time of consent for GDPR audit trail'
|
COMMENT ON COLUMN "cookie_consents"."ip_address" IS 'IP address at time of consent for GDPR audit trail'
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
await queryRunner.query(`DROP TABLE "cookie_consents"`);
|
await queryRunner.query(`DROP TABLE "cookie_consents"`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,92 +0,0 @@
|
|||||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rename subscription plans:
|
|
||||||
* FREE -> BRONZE, STARTER -> SILVER, PRO -> GOLD, ENTERPRISE -> PLATINIUM
|
|
||||||
*
|
|
||||||
* PostgreSQL does not support removing values from an enum type directly,
|
|
||||||
* so we create a new enum, migrate the column, and drop the old one.
|
|
||||||
*/
|
|
||||||
export class RenamePlansToBronzeSilverGoldPlatinium1740000000001 implements MigrationInterface {
|
|
||||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
|
||||||
// Step 1: Create new enum type
|
|
||||||
await queryRunner.query(
|
|
||||||
`CREATE TYPE "subscription_plan_enum_new" AS ENUM ('BRONZE', 'SILVER', 'GOLD', 'PLATINIUM')`
|
|
||||||
);
|
|
||||||
|
|
||||||
// Step 2: Convert the column to VARCHAR temporarily so we can update values
|
|
||||||
await queryRunner.query(`ALTER TABLE "subscriptions" ALTER COLUMN "plan" TYPE VARCHAR(20)`);
|
|
||||||
|
|
||||||
// Step 3: Update existing values
|
|
||||||
await queryRunner.query(`UPDATE "subscriptions" SET "plan" = 'BRONZE' WHERE "plan" = 'FREE'`);
|
|
||||||
await queryRunner.query(
|
|
||||||
`UPDATE "subscriptions" SET "plan" = 'SILVER' WHERE "plan" = 'STARTER'`
|
|
||||||
);
|
|
||||||
await queryRunner.query(`UPDATE "subscriptions" SET "plan" = 'GOLD' WHERE "plan" = 'PRO'`);
|
|
||||||
await queryRunner.query(
|
|
||||||
`UPDATE "subscriptions" SET "plan" = 'PLATINIUM' WHERE "plan" = 'ENTERPRISE'`
|
|
||||||
);
|
|
||||||
|
|
||||||
// Step 4: Drop existing default (required before changing enum type)
|
|
||||||
await queryRunner.query(`ALTER TABLE "subscriptions" ALTER COLUMN "plan" DROP DEFAULT`);
|
|
||||||
|
|
||||||
// Step 5: Set column to new enum type
|
|
||||||
await queryRunner.query(
|
|
||||||
`ALTER TABLE "subscriptions" ALTER COLUMN "plan" TYPE "subscription_plan_enum_new" USING "plan"::"subscription_plan_enum_new"`
|
|
||||||
);
|
|
||||||
|
|
||||||
// Step 6: Set new default
|
|
||||||
await queryRunner.query(`ALTER TABLE "subscriptions" ALTER COLUMN "plan" SET DEFAULT 'BRONZE'`);
|
|
||||||
|
|
||||||
// Step 7: Drop old enum type (name may vary — TypeORM often creates it as subscriptions_plan_enum)
|
|
||||||
// We handle both possible names
|
|
||||||
await queryRunner.query(`
|
|
||||||
DO $$ BEGIN
|
|
||||||
IF EXISTS (SELECT 1 FROM pg_type WHERE typname = 'subscriptions_plan_enum') THEN
|
|
||||||
DROP TYPE "subscriptions_plan_enum";
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Step 8: Rename new enum to standard name
|
|
||||||
await queryRunner.query(
|
|
||||||
`ALTER TYPE "subscription_plan_enum_new" RENAME TO "subscriptions_plan_enum"`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
|
||||||
// Reverse: create old enum, migrate back
|
|
||||||
await queryRunner.query(
|
|
||||||
`CREATE TYPE "subscription_plan_enum_old" AS ENUM ('FREE', 'STARTER', 'PRO', 'ENTERPRISE')`
|
|
||||||
);
|
|
||||||
|
|
||||||
await queryRunner.query(`ALTER TABLE "subscriptions" ALTER COLUMN "plan" TYPE VARCHAR(20)`);
|
|
||||||
|
|
||||||
await queryRunner.query(`UPDATE "subscriptions" SET "plan" = 'FREE' WHERE "plan" = 'BRONZE'`);
|
|
||||||
await queryRunner.query(
|
|
||||||
`UPDATE "subscriptions" SET "plan" = 'STARTER' WHERE "plan" = 'SILVER'`
|
|
||||||
);
|
|
||||||
await queryRunner.query(`UPDATE "subscriptions" SET "plan" = 'PRO' WHERE "plan" = 'GOLD'`);
|
|
||||||
await queryRunner.query(
|
|
||||||
`UPDATE "subscriptions" SET "plan" = 'ENTERPRISE' WHERE "plan" = 'PLATINIUM'`
|
|
||||||
);
|
|
||||||
|
|
||||||
await queryRunner.query(
|
|
||||||
`ALTER TABLE "subscriptions" ALTER COLUMN "plan" TYPE "subscription_plan_enum_old" USING "plan"::"subscription_plan_enum_old"`
|
|
||||||
);
|
|
||||||
|
|
||||||
await queryRunner.query(`ALTER TABLE "subscriptions" ALTER COLUMN "plan" SET DEFAULT 'FREE'`);
|
|
||||||
|
|
||||||
await queryRunner.query(`
|
|
||||||
DO $$ BEGIN
|
|
||||||
IF EXISTS (SELECT 1 FROM pg_type WHERE typname = 'subscriptions_plan_enum') THEN
|
|
||||||
DROP TYPE "subscriptions_plan_enum";
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
`);
|
|
||||||
|
|
||||||
await queryRunner.query(
|
|
||||||
`ALTER TYPE "subscription_plan_enum_old" RENAME TO "subscriptions_plan_enum"`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,43 +0,0 @@
|
|||||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
|
||||||
|
|
||||||
export class AddCommissionFields1740000000002 implements MigrationInterface {
|
|
||||||
name = 'AddCommissionFields1740000000002';
|
|
||||||
|
|
||||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
|
||||||
// Add commission columns to csv_bookings (bookings table may not exist yet)
|
|
||||||
await queryRunner.query(`
|
|
||||||
ALTER TABLE "csv_bookings"
|
|
||||||
ADD COLUMN IF NOT EXISTS "commission_rate" DECIMAL(5,2),
|
|
||||||
ADD COLUMN IF NOT EXISTS "commission_amount_eur" DECIMAL(12,2)
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Only alter bookings table if it exists
|
|
||||||
await queryRunner.query(`
|
|
||||||
DO $$ BEGIN
|
|
||||||
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'bookings') THEN
|
|
||||||
ALTER TABLE "bookings"
|
|
||||||
ADD COLUMN "commission_rate" DECIMAL(5,2),
|
|
||||||
ADD COLUMN "commission_amount_eur" DECIMAL(12,2);
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
|
||||||
await queryRunner.query(`
|
|
||||||
ALTER TABLE "csv_bookings"
|
|
||||||
DROP COLUMN IF EXISTS "commission_amount_eur",
|
|
||||||
DROP COLUMN IF EXISTS "commission_rate"
|
|
||||||
`);
|
|
||||||
|
|
||||||
await queryRunner.query(`
|
|
||||||
DO $$ BEGIN
|
|
||||||
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'bookings') THEN
|
|
||||||
ALTER TABLE "bookings"
|
|
||||||
DROP COLUMN "commission_amount_eur",
|
|
||||||
DROP COLUMN "commission_rate";
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
|
||||||
|
|
||||||
export class AddSiretAndStatusBadgeToOrganizations1740000000003 implements MigrationInterface {
|
|
||||||
name = 'AddSiretAndStatusBadgeToOrganizations1740000000003';
|
|
||||||
|
|
||||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
|
||||||
await queryRunner.query(`
|
|
||||||
ALTER TABLE "organizations"
|
|
||||||
ADD COLUMN "siret" VARCHAR(14),
|
|
||||||
ADD COLUMN "siret_verified" BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
ADD COLUMN "status_badge" VARCHAR(20) NOT NULL DEFAULT 'none'
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
|
||||||
await queryRunner.query(`
|
|
||||||
ALTER TABLE "organizations"
|
|
||||||
DROP COLUMN "status_badge",
|
|
||||||
DROP COLUMN "siret_verified",
|
|
||||||
DROP COLUMN "siret"
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,75 +0,0 @@
|
|||||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Migration: Add PENDING_PAYMENT status to csv_bookings enum + stripe_payment_intent_id column
|
|
||||||
*/
|
|
||||||
export class AddPendingPaymentStatus1740000000004 implements MigrationInterface {
|
|
||||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
|
||||||
// Drop the default before changing enum type
|
|
||||||
await queryRunner.query(`
|
|
||||||
ALTER TABLE "csv_bookings" ALTER COLUMN "status" DROP DEFAULT
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Create new enum with PENDING_PAYMENT
|
|
||||||
await queryRunner.query(`
|
|
||||||
CREATE TYPE "csv_booking_status_new" AS ENUM ('PENDING_PAYMENT', 'PENDING', 'ACCEPTED', 'REJECTED', 'CANCELLED')
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Swap column to new enum type
|
|
||||||
await queryRunner.query(`
|
|
||||||
ALTER TABLE "csv_bookings"
|
|
||||||
ALTER COLUMN "status" TYPE "csv_booking_status_new"
|
|
||||||
USING "status"::text::"csv_booking_status_new"
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Drop old enum and rename new
|
|
||||||
await queryRunner.query(`DROP TYPE "csv_booking_status"`);
|
|
||||||
await queryRunner.query(`ALTER TYPE "csv_booking_status_new" RENAME TO "csv_booking_status"`);
|
|
||||||
|
|
||||||
// Set new default
|
|
||||||
await queryRunner.query(`
|
|
||||||
ALTER TABLE "csv_bookings" ALTER COLUMN "status" SET DEFAULT 'PENDING_PAYMENT'
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Add stripe_payment_intent_id column
|
|
||||||
await queryRunner.query(`
|
|
||||||
ALTER TABLE "csv_bookings"
|
|
||||||
ADD COLUMN IF NOT EXISTS "stripe_payment_intent_id" VARCHAR(255)
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
|
||||||
// Remove stripe_payment_intent_id column
|
|
||||||
await queryRunner.query(`
|
|
||||||
ALTER TABLE "csv_bookings" DROP COLUMN IF EXISTS "stripe_payment_intent_id"
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Update any PENDING_PAYMENT rows to PENDING
|
|
||||||
await queryRunner.query(`
|
|
||||||
UPDATE "csv_bookings" SET "status" = 'PENDING' WHERE "status" = 'PENDING_PAYMENT'
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Drop default
|
|
||||||
await queryRunner.query(`
|
|
||||||
ALTER TABLE "csv_bookings" ALTER COLUMN "status" DROP DEFAULT
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Recreate original enum without PENDING_PAYMENT
|
|
||||||
await queryRunner.query(`
|
|
||||||
CREATE TYPE "csv_booking_status_old" AS ENUM ('PENDING', 'ACCEPTED', 'REJECTED', 'CANCELLED')
|
|
||||||
`);
|
|
||||||
|
|
||||||
await queryRunner.query(`
|
|
||||||
ALTER TABLE "csv_bookings"
|
|
||||||
ALTER COLUMN "status" TYPE "csv_booking_status_old"
|
|
||||||
USING "status"::text::"csv_booking_status_old"
|
|
||||||
`);
|
|
||||||
|
|
||||||
await queryRunner.query(`DROP TYPE "csv_booking_status"`);
|
|
||||||
await queryRunner.query(`ALTER TYPE "csv_booking_status_old" RENAME TO "csv_booking_status"`);
|
|
||||||
|
|
||||||
await queryRunner.query(`
|
|
||||||
ALTER TABLE "csv_bookings" ALTER COLUMN "status" SET DEFAULT 'PENDING'
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,75 +0,0 @@
|
|||||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Migration: Add PENDING_BANK_TRANSFER status to csv_bookings enum
|
|
||||||
*/
|
|
||||||
export class AddPendingBankTransferStatus1740000000005 implements MigrationInterface {
|
|
||||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
|
||||||
// Drop default before modifying enum
|
|
||||||
await queryRunner.query(`
|
|
||||||
ALTER TABLE "csv_bookings" ALTER COLUMN "status" DROP DEFAULT
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Create new enum with PENDING_BANK_TRANSFER
|
|
||||||
await queryRunner.query(`
|
|
||||||
CREATE TYPE "csv_booking_status_new" AS ENUM (
|
|
||||||
'PENDING_PAYMENT',
|
|
||||||
'PENDING_BANK_TRANSFER',
|
|
||||||
'PENDING',
|
|
||||||
'ACCEPTED',
|
|
||||||
'REJECTED',
|
|
||||||
'CANCELLED'
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Swap column to new enum type
|
|
||||||
await queryRunner.query(`
|
|
||||||
ALTER TABLE "csv_bookings"
|
|
||||||
ALTER COLUMN "status" TYPE "csv_booking_status_new"
|
|
||||||
USING "status"::text::"csv_booking_status_new"
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Drop old enum and rename new
|
|
||||||
await queryRunner.query(`DROP TYPE "csv_booking_status"`);
|
|
||||||
await queryRunner.query(`ALTER TYPE "csv_booking_status_new" RENAME TO "csv_booking_status"`);
|
|
||||||
|
|
||||||
// Restore default
|
|
||||||
await queryRunner.query(`
|
|
||||||
ALTER TABLE "csv_bookings" ALTER COLUMN "status" SET DEFAULT 'PENDING_PAYMENT'
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
|
||||||
// Move any PENDING_BANK_TRANSFER rows back to PENDING_PAYMENT
|
|
||||||
await queryRunner.query(`
|
|
||||||
UPDATE "csv_bookings" SET "status" = 'PENDING_PAYMENT' WHERE "status" = 'PENDING_BANK_TRANSFER'
|
|
||||||
`);
|
|
||||||
|
|
||||||
await queryRunner.query(`
|
|
||||||
ALTER TABLE "csv_bookings" ALTER COLUMN "status" DROP DEFAULT
|
|
||||||
`);
|
|
||||||
|
|
||||||
await queryRunner.query(`
|
|
||||||
CREATE TYPE "csv_booking_status_old" AS ENUM (
|
|
||||||
'PENDING_PAYMENT',
|
|
||||||
'PENDING',
|
|
||||||
'ACCEPTED',
|
|
||||||
'REJECTED',
|
|
||||||
'CANCELLED'
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
await queryRunner.query(`
|
|
||||||
ALTER TABLE "csv_bookings"
|
|
||||||
ALTER COLUMN "status" TYPE "csv_booking_status_old"
|
|
||||||
USING "status"::text::"csv_booking_status_old"
|
|
||||||
`);
|
|
||||||
|
|
||||||
await queryRunner.query(`DROP TYPE "csv_booking_status"`);
|
|
||||||
await queryRunner.query(`ALTER TYPE "csv_booking_status_old" RENAME TO "csv_booking_status"`);
|
|
||||||
|
|
||||||
await queryRunner.query(`
|
|
||||||
ALTER TABLE "csv_bookings" ALTER COLUMN "status" SET DEFAULT 'PENDING_PAYMENT'
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,32 +0,0 @@
|
|||||||
/**
|
|
||||||
* Shipment Counter Repository
|
|
||||||
*
|
|
||||||
* Counts total shipments (bookings + CSV bookings) for an organization in a year.
|
|
||||||
* Used to enforce Bronze plan's 12 shipments/year limit.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
|
||||||
import { Repository } from 'typeorm';
|
|
||||||
import { ShipmentCounterPort } from '@domain/ports/out/shipment-counter.port';
|
|
||||||
import { CsvBookingOrmEntity } from '../entities/csv-booking.orm-entity';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class TypeOrmShipmentCounterRepository implements ShipmentCounterPort {
|
|
||||||
constructor(
|
|
||||||
@InjectRepository(CsvBookingOrmEntity)
|
|
||||||
private readonly csvBookingRepository: Repository<CsvBookingOrmEntity>
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async countShipmentsForOrganizationInYear(organizationId: string, year: number): Promise<number> {
|
|
||||||
const startOfYear = new Date(year, 0, 1);
|
|
||||||
const startOfNextYear = new Date(year + 1, 0, 1);
|
|
||||||
|
|
||||||
return this.csvBookingRepository
|
|
||||||
.createQueryBuilder('csv_booking')
|
|
||||||
.where('csv_booking.organization_id = :organizationId', { organizationId })
|
|
||||||
.andWhere('csv_booking.created_at >= :start', { start: startOfYear })
|
|
||||||
.andWhere('csv_booking.created_at < :end', { end: startOfNextYear })
|
|
||||||
.getCount();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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> {
|
||||||
|
|||||||
@ -16,7 +16,7 @@ import { SubscriptionOrmMapper } from '../mappers/subscription-orm.mapper';
|
|||||||
export class TypeOrmSubscriptionRepository implements SubscriptionRepository {
|
export class TypeOrmSubscriptionRepository implements SubscriptionRepository {
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(SubscriptionOrmEntity)
|
@InjectRepository(SubscriptionOrmEntity)
|
||||||
private readonly repository: Repository<SubscriptionOrmEntity>
|
private readonly repository: Repository<SubscriptionOrmEntity>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async save(subscription: Subscription): Promise<Subscription> {
|
async save(subscription: Subscription): Promise<Subscription> {
|
||||||
@ -35,7 +35,9 @@ export class TypeOrmSubscriptionRepository implements SubscriptionRepository {
|
|||||||
return orm ? SubscriptionOrmMapper.toDomain(orm) : null;
|
return orm ? SubscriptionOrmMapper.toDomain(orm) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async findByStripeSubscriptionId(stripeSubscriptionId: string): Promise<Subscription | null> {
|
async findByStripeSubscriptionId(
|
||||||
|
stripeSubscriptionId: string,
|
||||||
|
): Promise<Subscription | null> {
|
||||||
const orm = await this.repository.findOne({ where: { stripeSubscriptionId } });
|
const orm = await this.repository.findOne({ where: { stripeSubscriptionId } });
|
||||||
return orm ? SubscriptionOrmMapper.toDomain(orm) : null;
|
return orm ? SubscriptionOrmMapper.toDomain(orm) : null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,8 +11,6 @@ import {
|
|||||||
StripePort,
|
StripePort,
|
||||||
CreateCheckoutSessionInput,
|
CreateCheckoutSessionInput,
|
||||||
CreateCheckoutSessionOutput,
|
CreateCheckoutSessionOutput,
|
||||||
CreateCommissionCheckoutInput,
|
|
||||||
CreateCommissionCheckoutOutput,
|
|
||||||
CreatePortalSessionInput,
|
CreatePortalSessionInput,
|
||||||
CreatePortalSessionOutput,
|
CreatePortalSessionOutput,
|
||||||
StripeSubscriptionData,
|
StripeSubscriptionData,
|
||||||
@ -44,46 +42,50 @@ export class StripeAdapter implements StripePort {
|
|||||||
this.planPriceMap = new Map();
|
this.planPriceMap = new Map();
|
||||||
|
|
||||||
// Configure plan price IDs from environment
|
// Configure plan price IDs from environment
|
||||||
const silverMonthly = this.configService.get<string>('STRIPE_SILVER_MONTHLY_PRICE_ID');
|
const starterMonthly = this.configService.get<string>('STRIPE_STARTER_MONTHLY_PRICE_ID');
|
||||||
const silverYearly = this.configService.get<string>('STRIPE_SILVER_YEARLY_PRICE_ID');
|
const starterYearly = this.configService.get<string>('STRIPE_STARTER_YEARLY_PRICE_ID');
|
||||||
const goldMonthly = this.configService.get<string>('STRIPE_GOLD_MONTHLY_PRICE_ID');
|
const proMonthly = this.configService.get<string>('STRIPE_PRO_MONTHLY_PRICE_ID');
|
||||||
const goldYearly = this.configService.get<string>('STRIPE_GOLD_YEARLY_PRICE_ID');
|
const proYearly = this.configService.get<string>('STRIPE_PRO_YEARLY_PRICE_ID');
|
||||||
const platiniumMonthly = this.configService.get<string>('STRIPE_PLATINIUM_MONTHLY_PRICE_ID');
|
const enterpriseMonthly = this.configService.get<string>('STRIPE_ENTERPRISE_MONTHLY_PRICE_ID');
|
||||||
const platiniumYearly = this.configService.get<string>('STRIPE_PLATINIUM_YEARLY_PRICE_ID');
|
const enterpriseYearly = this.configService.get<string>('STRIPE_ENTERPRISE_YEARLY_PRICE_ID');
|
||||||
|
|
||||||
if (silverMonthly) this.priceIdMap.set(silverMonthly, 'SILVER');
|
if (starterMonthly) this.priceIdMap.set(starterMonthly, 'STARTER');
|
||||||
if (silverYearly) this.priceIdMap.set(silverYearly, 'SILVER');
|
if (starterYearly) this.priceIdMap.set(starterYearly, 'STARTER');
|
||||||
if (goldMonthly) this.priceIdMap.set(goldMonthly, 'GOLD');
|
if (proMonthly) this.priceIdMap.set(proMonthly, 'PRO');
|
||||||
if (goldYearly) this.priceIdMap.set(goldYearly, 'GOLD');
|
if (proYearly) this.priceIdMap.set(proYearly, 'PRO');
|
||||||
if (platiniumMonthly) this.priceIdMap.set(platiniumMonthly, 'PLATINIUM');
|
if (enterpriseMonthly) this.priceIdMap.set(enterpriseMonthly, 'ENTERPRISE');
|
||||||
if (platiniumYearly) this.priceIdMap.set(platiniumYearly, 'PLATINIUM');
|
if (enterpriseYearly) this.priceIdMap.set(enterpriseYearly, 'ENTERPRISE');
|
||||||
|
|
||||||
this.planPriceMap.set('SILVER', {
|
this.planPriceMap.set('STARTER', {
|
||||||
monthly: silverMonthly || '',
|
monthly: starterMonthly || '',
|
||||||
yearly: silverYearly || '',
|
yearly: starterYearly || '',
|
||||||
});
|
});
|
||||||
this.planPriceMap.set('GOLD', {
|
this.planPriceMap.set('PRO', {
|
||||||
monthly: goldMonthly || '',
|
monthly: proMonthly || '',
|
||||||
yearly: goldYearly || '',
|
yearly: proYearly || '',
|
||||||
});
|
});
|
||||||
this.planPriceMap.set('PLATINIUM', {
|
this.planPriceMap.set('ENTERPRISE', {
|
||||||
monthly: platiniumMonthly || '',
|
monthly: enterpriseMonthly || '',
|
||||||
yearly: platiniumYearly || '',
|
yearly: enterpriseYearly || '',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async createCheckoutSession(
|
async createCheckoutSession(
|
||||||
input: CreateCheckoutSessionInput
|
input: CreateCheckoutSessionInput,
|
||||||
): Promise<CreateCheckoutSessionOutput> {
|
): Promise<CreateCheckoutSessionOutput> {
|
||||||
const planPrices = this.planPriceMap.get(input.plan);
|
const planPrices = this.planPriceMap.get(input.plan);
|
||||||
if (!planPrices) {
|
if (!planPrices) {
|
||||||
throw new Error(`No price configuration for plan: ${input.plan}`);
|
throw new Error(`No price configuration for plan: ${input.plan}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const priceId = input.billingInterval === 'yearly' ? planPrices.yearly : planPrices.monthly;
|
const priceId = input.billingInterval === 'yearly'
|
||||||
|
? planPrices.yearly
|
||||||
|
: planPrices.monthly;
|
||||||
|
|
||||||
if (!priceId) {
|
if (!priceId) {
|
||||||
throw new Error(`No ${input.billingInterval} price configured for plan: ${input.plan}`);
|
throw new Error(
|
||||||
|
`No ${input.billingInterval} price configured for plan: ${input.plan}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionParams: Stripe.Checkout.SessionCreateParams = {
|
const sessionParams: Stripe.Checkout.SessionCreateParams = {
|
||||||
@ -117,7 +119,7 @@ export class StripeAdapter implements StripePort {
|
|||||||
const session = await this.stripe.checkout.sessions.create(sessionParams);
|
const session = await this.stripe.checkout.sessions.create(sessionParams);
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Created checkout session ${session.id} for organization ${input.organizationId}`
|
`Created checkout session ${session.id} for organization ${input.organizationId}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -126,46 +128,9 @@ export class StripeAdapter implements StripePort {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async createCommissionCheckout(
|
async createPortalSession(
|
||||||
input: CreateCommissionCheckoutInput
|
input: CreatePortalSessionInput,
|
||||||
): Promise<CreateCommissionCheckoutOutput> {
|
): Promise<CreatePortalSessionOutput> {
|
||||||
const session = await this.stripe.checkout.sessions.create({
|
|
||||||
mode: 'payment',
|
|
||||||
payment_method_types: ['card'],
|
|
||||||
line_items: [
|
|
||||||
{
|
|
||||||
price_data: {
|
|
||||||
currency: input.currency,
|
|
||||||
unit_amount: input.amountCents,
|
|
||||||
product_data: {
|
|
||||||
name: 'Commission Xpeditis',
|
|
||||||
description: input.bookingDescription,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
quantity: 1,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
customer_email: input.customerEmail,
|
|
||||||
success_url: input.successUrl,
|
|
||||||
cancel_url: input.cancelUrl,
|
|
||||||
metadata: {
|
|
||||||
type: 'commission',
|
|
||||||
bookingId: input.bookingId,
|
|
||||||
organizationId: input.organizationId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
this.logger.log(
|
|
||||||
`Created commission checkout session ${session.id} for booking ${input.bookingId}`
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
sessionId: session.id,
|
|
||||||
sessionUrl: session.url || '',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async createPortalSession(input: CreatePortalSessionInput): Promise<CreatePortalSessionOutput> {
|
|
||||||
const session = await this.stripe.billingPortal.sessions.create({
|
const session = await this.stripe.billingPortal.sessions.create({
|
||||||
customer: input.customerId,
|
customer: input.customerId,
|
||||||
return_url: input.returnUrl,
|
return_url: input.returnUrl,
|
||||||
@ -246,9 +211,13 @@ export class StripeAdapter implements StripePort {
|
|||||||
|
|
||||||
async constructWebhookEvent(
|
async constructWebhookEvent(
|
||||||
payload: string | Buffer,
|
payload: string | Buffer,
|
||||||
signature: string
|
signature: string,
|
||||||
): Promise<StripeWebhookEvent> {
|
): Promise<StripeWebhookEvent> {
|
||||||
const event = this.stripe.webhooks.constructEvent(payload, signature, this.webhookSecret);
|
const event = this.stripe.webhooks.constructEvent(
|
||||||
|
payload,
|
||||||
|
signature,
|
||||||
|
this.webhookSecret,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: event.type,
|
type: event.type,
|
||||||
|
|||||||
@ -1,308 +1,413 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { getAllBookings, validateBankTransfer } from '@/lib/api/admin';
|
import { getAllBookings } from '@/lib/api/admin';
|
||||||
|
|
||||||
interface Booking {
|
interface Booking {
|
||||||
id: string;
|
id: string;
|
||||||
bookingNumber?: string | null;
|
bookingNumber?: string;
|
||||||
type?: string;
|
bookingId?: string;
|
||||||
status: string;
|
type?: string;
|
||||||
origin?: string;
|
status: string;
|
||||||
destination?: string;
|
// CSV bookings use these fields
|
||||||
carrierName?: string;
|
origin?: string;
|
||||||
containerType: string;
|
destination?: string;
|
||||||
volumeCBM?: number;
|
carrierName?: string;
|
||||||
weightKG?: number;
|
// Regular bookings use these fields
|
||||||
palletCount?: number;
|
originPort?: {
|
||||||
priceEUR?: number;
|
code: string;
|
||||||
priceUSD?: number;
|
name: string;
|
||||||
primaryCurrency?: string;
|
};
|
||||||
createdAt?: string;
|
destinationPort?: {
|
||||||
requestedAt?: string;
|
code: string;
|
||||||
updatedAt?: string;
|
name: string;
|
||||||
organizationId?: string;
|
};
|
||||||
userId?: string;
|
carrier?: string;
|
||||||
}
|
containerType: string;
|
||||||
|
quantity?: number;
|
||||||
export default function AdminBookingsPage() {
|
price?: number;
|
||||||
const [bookings, setBookings] = useState<Booking[]>([]);
|
primaryCurrency?: string;
|
||||||
const [loading, setLoading] = useState(true);
|
totalPrice?: {
|
||||||
const [error, setError] = useState<string | null>(null);
|
amount: number;
|
||||||
const [filterStatus, setFilterStatus] = useState('all');
|
currency: string;
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
};
|
||||||
const [validatingId, setValidatingId] = useState<string | null>(null);
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
useEffect(() => {
|
requestedAt?: string;
|
||||||
fetchBookings();
|
organizationId?: string;
|
||||||
}, []);
|
userId?: string;
|
||||||
|
}
|
||||||
const handleValidateTransfer = async (bookingId: string) => {
|
|
||||||
if (!window.confirm('Confirmer la réception du virement et activer ce booking ?')) return;
|
export default function AdminBookingsPage() {
|
||||||
setValidatingId(bookingId);
|
const [bookings, setBookings] = useState<Booking[]>([]);
|
||||||
try {
|
const [loading, setLoading] = useState(true);
|
||||||
await validateBankTransfer(bookingId);
|
const [error, setError] = useState<string | null>(null);
|
||||||
await fetchBookings();
|
const [selectedBooking, setSelectedBooking] = useState<Booking | null>(null);
|
||||||
} catch (err: any) {
|
const [showDetailsModal, setShowDetailsModal] = useState(false);
|
||||||
setError(err.message || 'Erreur lors de la validation du virement');
|
const [filterStatus, setFilterStatus] = useState('all');
|
||||||
} finally {
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
setValidatingId(null);
|
|
||||||
}
|
// Helper function to get formatted quote number
|
||||||
};
|
const getQuoteNumber = (booking: Booking): string => {
|
||||||
|
if (booking.type === 'csv') {
|
||||||
const fetchBookings = async () => {
|
return `#${booking.bookingId || booking.id.slice(0, 8).toUpperCase()}`;
|
||||||
try {
|
}
|
||||||
setLoading(true);
|
return booking.bookingNumber || `#${booking.id.slice(0, 8).toUpperCase()}`;
|
||||||
const response = await getAllBookings();
|
};
|
||||||
setBookings(response.bookings || []);
|
|
||||||
setError(null);
|
useEffect(() => {
|
||||||
} catch (err: any) {
|
fetchBookings();
|
||||||
setError(err.message || 'Impossible de charger les réservations');
|
}, []);
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
const fetchBookings = async () => {
|
||||||
}
|
try {
|
||||||
};
|
setLoading(true);
|
||||||
|
const response = await getAllBookings();
|
||||||
const getStatusColor = (status: string) => {
|
setBookings(response.bookings || []);
|
||||||
const colors: Record<string, string> = {
|
setError(null);
|
||||||
pending_payment: 'bg-orange-100 text-orange-800',
|
} catch (err: any) {
|
||||||
pending_bank_transfer: 'bg-amber-100 text-amber-900',
|
setError(err.message || 'Failed to load bookings');
|
||||||
pending: 'bg-yellow-100 text-yellow-800',
|
} finally {
|
||||||
accepted: 'bg-green-100 text-green-800',
|
setLoading(false);
|
||||||
rejected: 'bg-red-100 text-red-800',
|
}
|
||||||
cancelled: 'bg-red-100 text-red-800',
|
};
|
||||||
};
|
|
||||||
return colors[status.toLowerCase()] || 'bg-gray-100 text-gray-800';
|
const getStatusColor = (status: string) => {
|
||||||
};
|
const colors: Record<string, string> = {
|
||||||
|
draft: 'bg-gray-100 text-gray-800',
|
||||||
const getStatusLabel = (status: string) => {
|
pending: 'bg-yellow-100 text-yellow-800',
|
||||||
const labels: Record<string, string> = {
|
confirmed: 'bg-blue-100 text-blue-800',
|
||||||
PENDING_PAYMENT: 'Paiement en attente',
|
in_transit: 'bg-purple-100 text-purple-800',
|
||||||
PENDING_BANK_TRANSFER: 'Virement à valider',
|
delivered: 'bg-green-100 text-green-800',
|
||||||
PENDING: 'En attente transporteur',
|
cancelled: 'bg-red-100 text-red-800',
|
||||||
ACCEPTED: 'Accepté',
|
};
|
||||||
REJECTED: 'Rejeté',
|
return colors[status.toLowerCase()] || 'bg-gray-100 text-gray-800';
|
||||||
CANCELLED: 'Annulé',
|
};
|
||||||
};
|
|
||||||
return labels[status.toUpperCase()] || status;
|
const filteredBookings = bookings
|
||||||
};
|
.filter(booking => filterStatus === 'all' || booking.status.toLowerCase() === filterStatus)
|
||||||
|
.filter(booking => {
|
||||||
const getShortId = (booking: Booking) => `#${booking.id.slice(0, 8).toUpperCase()}`;
|
if (searchTerm === '') return true;
|
||||||
|
const searchLower = searchTerm.toLowerCase();
|
||||||
const filteredBookings = bookings
|
const quoteNumber = getQuoteNumber(booking).toLowerCase();
|
||||||
.filter(booking => filterStatus === 'all' || booking.status.toLowerCase() === filterStatus)
|
return (
|
||||||
.filter(booking => {
|
quoteNumber.includes(searchLower) ||
|
||||||
if (searchTerm === '') return true;
|
booking.bookingNumber?.toLowerCase().includes(searchLower) ||
|
||||||
const s = searchTerm.toLowerCase();
|
booking.carrier?.toLowerCase().includes(searchLower) ||
|
||||||
return (
|
booking.carrierName?.toLowerCase().includes(searchLower) ||
|
||||||
booking.bookingNumber?.toLowerCase().includes(s) ||
|
booking.origin?.toLowerCase().includes(searchLower) ||
|
||||||
booking.id.toLowerCase().includes(s) ||
|
booking.destination?.toLowerCase().includes(searchLower)
|
||||||
booking.carrierName?.toLowerCase().includes(s) ||
|
);
|
||||||
booking.origin?.toLowerCase().includes(s) ||
|
});
|
||||||
booking.destination?.toLowerCase().includes(s) ||
|
|
||||||
String(booking.palletCount || '').includes(s) ||
|
if (loading) {
|
||||||
String(booking.weightKG || '').includes(s) ||
|
return (
|
||||||
String(booking.volumeCBM || '').includes(s) ||
|
<div className="flex items-center justify-center h-96">
|
||||||
booking.containerType?.toLowerCase().includes(s)
|
<div className="text-center">
|
||||||
);
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
|
||||||
});
|
<p className="mt-4 text-gray-600">Loading bookings...</p>
|
||||||
|
</div>
|
||||||
if (loading) {
|
</div>
|
||||||
return (
|
);
|
||||||
<div className="flex items-center justify-center h-96">
|
}
|
||||||
<div className="text-center">
|
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
|
return (
|
||||||
<p className="mt-4 text-gray-600">Chargement des réservations...</p>
|
<div className="space-y-6">
|
||||||
</div>
|
{/* Header */}
|
||||||
</div>
|
<div className="flex items-center justify-between">
|
||||||
);
|
<div>
|
||||||
}
|
<h1 className="text-2xl font-bold text-gray-900">Booking Management</h1>
|
||||||
|
<p className="mt-1 text-sm text-gray-500">
|
||||||
return (
|
View and manage all bookings across the platform
|
||||||
<div className="space-y-6">
|
</p>
|
||||||
{/* Header */}
|
</div>
|
||||||
<div>
|
</div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Gestion des réservations</h1>
|
|
||||||
<p className="mt-1 text-sm text-gray-500">
|
{/* Filters */}
|
||||||
Toutes les réservations de la plateforme
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||||
</p>
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
</div>
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">Search</label>
|
||||||
{/* Stats Cards */}
|
<input
|
||||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
type="text"
|
||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
placeholder="Search by booking number or carrier..."
|
||||||
<div className="text-xs text-gray-500 uppercase tracking-wide">Total</div>
|
value={searchTerm}
|
||||||
<div className="text-2xl font-bold text-gray-900 mt-1">{bookings.length}</div>
|
onChange={e => setSearchTerm(e.target.value)}
|
||||||
</div>
|
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
|
||||||
<div className="bg-amber-50 rounded-lg shadow-sm border border-amber-200 p-4">
|
/>
|
||||||
<div className="text-xs text-amber-700 uppercase tracking-wide">Virements à valider</div>
|
</div>
|
||||||
<div className="text-2xl font-bold text-amber-700 mt-1">
|
<div>
|
||||||
{bookings.filter(b => b.status.toUpperCase() === 'PENDING_BANK_TRANSFER').length}
|
<label className="block text-sm font-medium text-gray-700 mb-2">Status Filter</label>
|
||||||
</div>
|
<select
|
||||||
</div>
|
value={filterStatus}
|
||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
onChange={e => setFilterStatus(e.target.value)}
|
||||||
<div className="text-xs text-gray-500 uppercase tracking-wide">En attente transporteur</div>
|
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
|
||||||
<div className="text-2xl font-bold text-yellow-600 mt-1">
|
>
|
||||||
{bookings.filter(b => b.status.toUpperCase() === 'PENDING').length}
|
<option value="all">All Statuses</option>
|
||||||
</div>
|
<option value="draft">Draft</option>
|
||||||
</div>
|
<option value="pending">Pending</option>
|
||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
<option value="confirmed">Confirmed</option>
|
||||||
<div className="text-xs text-gray-500 uppercase tracking-wide">Acceptées</div>
|
<option value="in_transit">In Transit</option>
|
||||||
<div className="text-2xl font-bold text-green-600 mt-1">
|
<option value="delivered">Delivered</option>
|
||||||
{bookings.filter(b => b.status.toUpperCase() === 'ACCEPTED').length}
|
<option value="cancelled">Cancelled</option>
|
||||||
</div>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
</div>
|
||||||
<div className="text-xs text-gray-500 uppercase tracking-wide">Rejetées</div>
|
</div>
|
||||||
<div className="text-2xl font-bold text-red-600 mt-1">
|
|
||||||
{bookings.filter(b => b.status.toUpperCase() === 'REJECTED').length}
|
{/* Stats Cards */}
|
||||||
</div>
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
</div>
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||||
</div>
|
<div className="text-sm text-gray-500">Total Réservations</div>
|
||||||
|
<div className="text-2xl font-bold text-gray-900">{bookings.length}</div>
|
||||||
{/* Filters */}
|
</div>
|
||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="text-sm text-gray-500">En Attente</div>
|
||||||
<div>
|
<div className="text-2xl font-bold text-yellow-600">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Recherche</label>
|
{bookings.filter(b => b.status.toUpperCase() === 'PENDING').length}
|
||||||
<input
|
</div>
|
||||||
type="text"
|
</div>
|
||||||
placeholder="N° booking, transporteur, route, palettes, poids, CBM..."
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||||
value={searchTerm}
|
<div className="text-sm text-gray-500">Acceptées</div>
|
||||||
onChange={e => setSearchTerm(e.target.value)}
|
<div className="text-2xl font-bold text-green-600">
|
||||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none text-sm"
|
{bookings.filter(b => b.status.toUpperCase() === 'ACCEPTED').length}
|
||||||
/>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Statut</label>
|
<div className="text-sm text-gray-500">Rejetées</div>
|
||||||
<select
|
<div className="text-2xl font-bold text-red-600">
|
||||||
value={filterStatus}
|
{bookings.filter(b => b.status.toUpperCase() === 'REJECTED').length}
|
||||||
onChange={e => setFilterStatus(e.target.value)}
|
</div>
|
||||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none text-sm"
|
</div>
|
||||||
>
|
</div>
|
||||||
<option value="all">Tous les statuts</option>
|
|
||||||
<option value="pending_bank_transfer">Virement à valider</option>
|
{/* Error Message */}
|
||||||
<option value="pending_payment">Paiement en attente</option>
|
{error && (
|
||||||
<option value="pending">En attente transporteur</option>
|
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg">
|
||||||
<option value="accepted">Accepté</option>
|
{error}
|
||||||
<option value="rejected">Rejeté</option>
|
</div>
|
||||||
<option value="cancelled">Annulé</option>
|
)}
|
||||||
</select>
|
|
||||||
</div>
|
{/* Bookings Table */}
|
||||||
</div>
|
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||||
</div>
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
{/* Error Message */}
|
<tr>
|
||||||
{error && (
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg text-sm">
|
Numéro de devis
|
||||||
{error}
|
</th>
|
||||||
</div>
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
)}
|
Route
|
||||||
|
</th>
|
||||||
{/* Bookings Table */}
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
Transporteur
|
||||||
<div className="overflow-x-auto">
|
</th>
|
||||||
<table className="min-w-full divide-y divide-gray-200">
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
<thead className="bg-gray-50">
|
Conteneur
|
||||||
<tr>
|
</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
N° Booking
|
Statut
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Route
|
Prix
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Cargo
|
Actions
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
</tr>
|
||||||
Transporteur
|
</thead>
|
||||||
</th>
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
{filteredBookings.map(booking => (
|
||||||
Statut
|
<tr key={booking.id} className="hover:bg-gray-50">
|
||||||
</th>
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<div className="text-sm font-medium text-gray-900">
|
||||||
Date
|
{getQuoteNumber(booking)}
|
||||||
</th>
|
</div>
|
||||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<div className="text-xs text-gray-500">
|
||||||
Actions
|
{new Date(booking.createdAt || booking.requestedAt || '').toLocaleDateString()}
|
||||||
</th>
|
</div>
|
||||||
</tr>
|
</td>
|
||||||
</thead>
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
<div className="text-sm text-gray-900">
|
||||||
{filteredBookings.length === 0 ? (
|
{booking.originPort ? `${booking.originPort.code} → ${booking.destinationPort?.code}` : `${booking.origin} → ${booking.destination}`}
|
||||||
<tr>
|
</div>
|
||||||
<td colSpan={7} className="px-4 py-8 text-center text-sm text-gray-500">
|
<div className="text-xs text-gray-500">
|
||||||
Aucune réservation trouvée
|
{booking.originPort ? `${booking.originPort.name} → ${booking.destinationPort?.name}` : ''}
|
||||||
</td>
|
</div>
|
||||||
</tr>
|
</td>
|
||||||
) : (
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
filteredBookings.map(booking => (
|
{booking.carrier || booking.carrierName || 'N/A'}
|
||||||
<tr key={booking.id} className="hover:bg-gray-50">
|
</td>
|
||||||
{/* N° Booking */}
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<td className="px-4 py-4 whitespace-nowrap">
|
<div className="text-sm text-gray-900">{booking.containerType}</div>
|
||||||
{booking.bookingNumber && (
|
<div className="text-xs text-gray-500">
|
||||||
<div className="text-sm font-semibold text-gray-900">{booking.bookingNumber}</div>
|
{booking.quantity ? `Qty: ${booking.quantity}` : ''}
|
||||||
)}
|
</div>
|
||||||
<div className="text-xs text-gray-400 font-mono">{getShortId(booking)}</div>
|
</td>
|
||||||
</td>
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(booking.status)}`}>
|
||||||
{/* Route */}
|
{booking.status}
|
||||||
<td className="px-4 py-4 whitespace-nowrap">
|
</span>
|
||||||
<div className="text-sm font-medium text-gray-900">
|
</td>
|
||||||
{booking.origin} → {booking.destination}
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
</div>
|
{booking.totalPrice
|
||||||
</td>
|
? `${booking.totalPrice.amount.toLocaleString()} ${booking.totalPrice.currency}`
|
||||||
|
: booking.price
|
||||||
{/* Cargo */}
|
? `${booking.price.toLocaleString()} ${booking.primaryCurrency || 'USD'}`
|
||||||
<td className="px-4 py-4 whitespace-nowrap">
|
: 'N/A'
|
||||||
<div className="text-sm text-gray-900">
|
}
|
||||||
{booking.containerType}
|
</td>
|
||||||
{booking.palletCount != null && (
|
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||||
<span className="ml-1 text-gray-500">· {booking.palletCount} pal.</span>
|
<button
|
||||||
)}
|
onClick={() => {
|
||||||
</div>
|
setSelectedBooking(booking);
|
||||||
<div className="text-xs text-gray-500 space-x-2">
|
setShowDetailsModal(true);
|
||||||
{booking.weightKG != null && <span>{booking.weightKG.toLocaleString()} kg</span>}
|
}}
|
||||||
{booking.volumeCBM != null && <span>{booking.volumeCBM} CBM</span>}
|
className="text-blue-600 hover:text-blue-900"
|
||||||
</div>
|
>
|
||||||
</td>
|
View Details
|
||||||
|
</button>
|
||||||
{/* Transporteur */}
|
</td>
|
||||||
<td className="px-4 py-4 whitespace-nowrap text-sm text-gray-900">
|
</tr>
|
||||||
{booking.carrierName || '—'}
|
))}
|
||||||
</td>
|
</tbody>
|
||||||
|
</table>
|
||||||
{/* Statut */}
|
</div>
|
||||||
<td className="px-4 py-4 whitespace-nowrap">
|
|
||||||
<span className={`px-2 py-0.5 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(booking.status)}`}>
|
{/* Details Modal */}
|
||||||
{getStatusLabel(booking.status)}
|
{showDetailsModal && selectedBooking && (
|
||||||
</span>
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 overflow-y-auto">
|
||||||
</td>
|
<div className="bg-white rounded-lg p-6 max-w-2xl w-full m-4">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
{/* Date */}
|
<h2 className="text-xl font-bold">Booking Details</h2>
|
||||||
<td className="px-4 py-4 whitespace-nowrap text-sm text-gray-500">
|
<button
|
||||||
{new Date(booking.requestedAt || booking.createdAt || '').toLocaleDateString('fr-FR')}
|
onClick={() => {
|
||||||
</td>
|
setShowDetailsModal(false);
|
||||||
|
setSelectedBooking(null);
|
||||||
{/* Actions */}
|
}}
|
||||||
<td className="px-4 py-4 whitespace-nowrap text-right text-sm">
|
className="text-gray-400 hover:text-gray-600"
|
||||||
{booking.status.toUpperCase() === 'PENDING_BANK_TRANSFER' && (
|
>
|
||||||
<button
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
onClick={() => handleValidateTransfer(booking.id)}
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
disabled={validatingId === booking.id}
|
</svg>
|
||||||
className="px-3 py-1 bg-green-600 text-white text-xs font-semibold rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
</button>
|
||||||
>
|
</div>
|
||||||
{validatingId === booking.id ? '...' : '✓ Valider virement'}
|
|
||||||
</button>
|
<div className="space-y-4">
|
||||||
)}
|
<div className="grid grid-cols-2 gap-4">
|
||||||
</td>
|
<div>
|
||||||
</tr>
|
<label className="block text-sm font-medium text-gray-500">Numéro de devis</label>
|
||||||
))
|
<div className="mt-1 text-lg font-semibold">
|
||||||
)}
|
{getQuoteNumber(selectedBooking)}
|
||||||
</tbody>
|
</div>
|
||||||
</table>
|
</div>
|
||||||
</div>
|
<div>
|
||||||
</div>
|
<label className="block text-sm font-medium text-gray-500">Statut</label>
|
||||||
</div>
|
<span className={`mt-1 inline-block px-3 py-1 text-sm font-semibold rounded-full ${getStatusColor(selectedBooking.status)}`}>
|
||||||
);
|
{selectedBooking.status}
|
||||||
}
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t pt-4">
|
||||||
|
<h3 className="text-sm font-medium text-gray-900 mb-3">Route Information</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-500">Origin</label>
|
||||||
|
<div className="mt-1">
|
||||||
|
{selectedBooking.originPort ? (
|
||||||
|
<>
|
||||||
|
<div className="font-semibold">{selectedBooking.originPort.code}</div>
|
||||||
|
<div className="text-sm text-gray-600">{selectedBooking.originPort.name}</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="font-semibold">{selectedBooking.origin}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-500">Destination</label>
|
||||||
|
<div className="mt-1">
|
||||||
|
{selectedBooking.destinationPort ? (
|
||||||
|
<>
|
||||||
|
<div className="font-semibold">{selectedBooking.destinationPort.code}</div>
|
||||||
|
<div className="text-sm text-gray-600">{selectedBooking.destinationPort.name}</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="font-semibold">{selectedBooking.destination}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t pt-4">
|
||||||
|
<h3 className="text-sm font-medium text-gray-900 mb-3">Shipping Details</h3>
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-500">Carrier</label>
|
||||||
|
<div className="mt-1 font-semibold">
|
||||||
|
{selectedBooking.carrier || selectedBooking.carrierName || 'N/A'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-500">Container Type</label>
|
||||||
|
<div className="mt-1 font-semibold">{selectedBooking.containerType}</div>
|
||||||
|
</div>
|
||||||
|
{selectedBooking.quantity && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-500">Quantity</label>
|
||||||
|
<div className="mt-1 font-semibold">{selectedBooking.quantity}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t pt-4">
|
||||||
|
<h3 className="text-sm font-medium text-gray-900 mb-3">Pricing</h3>
|
||||||
|
<div className="text-2xl font-bold text-blue-600">
|
||||||
|
{selectedBooking.totalPrice
|
||||||
|
? `${selectedBooking.totalPrice.amount.toLocaleString()} ${selectedBooking.totalPrice.currency}`
|
||||||
|
: selectedBooking.price
|
||||||
|
? `${selectedBooking.price.toLocaleString()} ${selectedBooking.primaryCurrency || 'USD'}`
|
||||||
|
: 'N/A'
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t pt-4">
|
||||||
|
<h3 className="text-sm font-medium text-gray-900 mb-3">Timeline</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<label className="block text-gray-500">Created</label>
|
||||||
|
<div className="mt-1">
|
||||||
|
{new Date(selectedBooking.createdAt || selectedBooking.requestedAt || '').toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{selectedBooking.updatedAt && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-gray-500">Last Updated</label>
|
||||||
|
<div className="mt-1">{new Date(selectedBooking.updatedAt).toLocaleString()}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end space-x-2 mt-6 pt-4 border-t">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowDetailsModal(false);
|
||||||
|
setSelectedBooking(null);
|
||||||
|
}}
|
||||||
|
className="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { getAllOrganizations, verifySiret, approveSiret, rejectSiret } from '@/lib/api/admin';
|
import { getAllOrganizations } from '@/lib/api/admin';
|
||||||
import { createOrganization, updateOrganization } from '@/lib/api/organizations';
|
import { createOrganization, updateOrganization } from '@/lib/api/organizations';
|
||||||
|
|
||||||
interface Organization {
|
interface Organization {
|
||||||
@ -10,9 +10,6 @@ interface Organization {
|
|||||||
type: string;
|
type: string;
|
||||||
scac?: string;
|
scac?: string;
|
||||||
siren?: string;
|
siren?: string;
|
||||||
siret?: string;
|
|
||||||
siretVerified?: boolean;
|
|
||||||
statusBadge?: string;
|
|
||||||
eori?: string;
|
eori?: string;
|
||||||
contact_phone?: string;
|
contact_phone?: string;
|
||||||
contact_email?: string;
|
contact_email?: string;
|
||||||
@ -35,7 +32,6 @@ export default function AdminOrganizationsPage() {
|
|||||||
const [selectedOrg, setSelectedOrg] = useState<Organization | null>(null);
|
const [selectedOrg, setSelectedOrg] = useState<Organization | null>(null);
|
||||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
const [showEditModal, setShowEditModal] = useState(false);
|
const [showEditModal, setShowEditModal] = useState(false);
|
||||||
const [verifyingId, setVerifyingId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Form state
|
// Form state
|
||||||
const [formData, setFormData] = useState<{
|
const [formData, setFormData] = useState<{
|
||||||
@ -43,7 +39,6 @@ export default function AdminOrganizationsPage() {
|
|||||||
type: string;
|
type: string;
|
||||||
scac: string;
|
scac: string;
|
||||||
siren: string;
|
siren: string;
|
||||||
siret: string;
|
|
||||||
eori: string;
|
eori: string;
|
||||||
contact_phone: string;
|
contact_phone: string;
|
||||||
contact_email: string;
|
contact_email: string;
|
||||||
@ -60,7 +55,6 @@ export default function AdminOrganizationsPage() {
|
|||||||
type: 'FREIGHT_FORWARDER',
|
type: 'FREIGHT_FORWARDER',
|
||||||
scac: '',
|
scac: '',
|
||||||
siren: '',
|
siren: '',
|
||||||
siret: '',
|
|
||||||
eori: '',
|
eori: '',
|
||||||
contact_phone: '',
|
contact_phone: '',
|
||||||
contact_email: '',
|
contact_email: '',
|
||||||
@ -136,7 +130,6 @@ export default function AdminOrganizationsPage() {
|
|||||||
type: 'FREIGHT_FORWARDER',
|
type: 'FREIGHT_FORWARDER',
|
||||||
scac: '',
|
scac: '',
|
||||||
siren: '',
|
siren: '',
|
||||||
siret: '',
|
|
||||||
eori: '',
|
eori: '',
|
||||||
contact_phone: '',
|
contact_phone: '',
|
||||||
contact_email: '',
|
contact_email: '',
|
||||||
@ -151,51 +144,6 @@ export default function AdminOrganizationsPage() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleVerifySiret = async (orgId: string) => {
|
|
||||||
try {
|
|
||||||
setVerifyingId(orgId);
|
|
||||||
const result = await verifySiret(orgId);
|
|
||||||
if (result.verified) {
|
|
||||||
alert(`SIRET verifie avec succes !\nEntreprise: ${result.companyName || 'N/A'}\nAdresse: ${result.address || 'N/A'}`);
|
|
||||||
await fetchOrganizations();
|
|
||||||
} else {
|
|
||||||
alert(result.message || 'SIRET invalide ou introuvable.');
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
alert(err.message || 'Erreur lors de la verification du SIRET');
|
|
||||||
} finally {
|
|
||||||
setVerifyingId(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleApproveSiret = async (orgId: string) => {
|
|
||||||
if (!confirm('Confirmer l\'approbation manuelle du SIRET/SIREN de cette organisation ?')) return;
|
|
||||||
try {
|
|
||||||
setVerifyingId(orgId);
|
|
||||||
const result = await approveSiret(orgId);
|
|
||||||
alert(result.message);
|
|
||||||
await fetchOrganizations();
|
|
||||||
} catch (err: any) {
|
|
||||||
alert(err.message || 'Erreur lors de l\'approbation');
|
|
||||||
} finally {
|
|
||||||
setVerifyingId(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRejectSiret = async (orgId: string) => {
|
|
||||||
if (!confirm('Confirmer le refus du SIRET/SIREN ? L\'organisation ne pourra plus effectuer d\'achats.')) return;
|
|
||||||
try {
|
|
||||||
setVerifyingId(orgId);
|
|
||||||
const result = await rejectSiret(orgId);
|
|
||||||
alert(result.message);
|
|
||||||
await fetchOrganizations();
|
|
||||||
} catch (err: any) {
|
|
||||||
alert(err.message || 'Erreur lors du refus');
|
|
||||||
} finally {
|
|
||||||
setVerifyingId(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const openEditModal = (org: Organization) => {
|
const openEditModal = (org: Organization) => {
|
||||||
setSelectedOrg(org);
|
setSelectedOrg(org);
|
||||||
setFormData({
|
setFormData({
|
||||||
@ -203,7 +151,6 @@ export default function AdminOrganizationsPage() {
|
|||||||
type: org.type,
|
type: org.type,
|
||||||
scac: org.scac || '',
|
scac: org.scac || '',
|
||||||
siren: org.siren || '',
|
siren: org.siren || '',
|
||||||
siret: org.siret || '',
|
|
||||||
eori: org.eori || '',
|
eori: org.eori || '',
|
||||||
contact_phone: org.contact_phone || '',
|
contact_phone: org.contact_phone || '',
|
||||||
contact_email: org.contact_email || '',
|
contact_email: org.contact_email || '',
|
||||||
@ -282,25 +229,6 @@ export default function AdminOrganizationsPage() {
|
|||||||
<span className="font-medium">SIREN:</span> {org.siren}
|
<span className="font-medium">SIREN:</span> {org.siren}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="font-medium">SIRET:</span>
|
|
||||||
{org.siret ? (
|
|
||||||
<>
|
|
||||||
<span>{org.siret}</span>
|
|
||||||
{org.siretVerified ? (
|
|
||||||
<span className="inline-flex items-center px-2 py-0.5 text-xs font-semibold rounded-full bg-green-100 text-green-800">
|
|
||||||
Verifie
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="inline-flex items-center px-2 py-0.5 text-xs font-semibold rounded-full bg-yellow-100 text-yellow-800">
|
|
||||||
Non verifie
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<span className="text-gray-400">Non renseigne</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{org.contact_email && (
|
{org.contact_email && (
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium">Email:</span> {org.contact_email}
|
<span className="font-medium">Email:</span> {org.contact_email}
|
||||||
@ -311,45 +239,13 @@ export default function AdminOrganizationsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="flex space-x-2">
|
||||||
<div className="flex space-x-2">
|
<button
|
||||||
<button
|
onClick={() => openEditModal(org)}
|
||||||
onClick={() => openEditModal(org)}
|
className="flex-1 px-3 py-2 bg-blue-50 text-blue-700 rounded-md hover:bg-blue-100 transition-colors text-sm font-medium"
|
||||||
className="flex-1 px-3 py-2 bg-blue-50 text-blue-700 rounded-md hover:bg-blue-100 transition-colors text-sm font-medium"
|
>
|
||||||
>
|
Edit
|
||||||
Edit
|
</button>
|
||||||
</button>
|
|
||||||
{org.siret && !org.siretVerified && (
|
|
||||||
<button
|
|
||||||
onClick={() => handleVerifySiret(org.id)}
|
|
||||||
disabled={verifyingId === org.id}
|
|
||||||
className="flex-1 px-3 py-2 bg-purple-50 text-purple-700 rounded-md hover:bg-purple-100 transition-colors text-sm font-medium disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{verifyingId === org.id ? '...' : 'Verifier API'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{(org.siret || org.siren) && (
|
|
||||||
<div className="flex space-x-2">
|
|
||||||
{!org.siretVerified ? (
|
|
||||||
<button
|
|
||||||
onClick={() => handleApproveSiret(org.id)}
|
|
||||||
disabled={verifyingId === org.id}
|
|
||||||
className="flex-1 px-3 py-2 bg-green-50 text-green-700 rounded-md hover:bg-green-100 transition-colors text-sm font-medium disabled:opacity-50"
|
|
||||||
>
|
|
||||||
Approuver SIRET
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
onClick={() => handleRejectSiret(org.id)}
|
|
||||||
disabled={verifyingId === org.id}
|
|
||||||
className="flex-1 px-3 py-2 bg-red-50 text-red-700 rounded-md hover:bg-red-100 transition-colors text-sm font-medium disabled:opacity-50"
|
|
||||||
>
|
|
||||||
Rejeter SIRET
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@ -413,18 +309,6 @@ export default function AdminOrganizationsPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700">SIRET (14 chiffres)</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
maxLength={14}
|
|
||||||
value={formData.siret}
|
|
||||||
onChange={e => setFormData({ ...formData, siret: e.target.value.replace(/\D/g, '') })}
|
|
||||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
|
|
||||||
placeholder="12345678901234"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700">EORI</label>
|
<label className="block text-sm font-medium text-gray-700">EORI</label>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@ -1,437 +0,0 @@
|
|||||||
/**
|
|
||||||
* Commission Payment Page
|
|
||||||
*
|
|
||||||
* 2-column layout:
|
|
||||||
* - Left: payment method selector + action
|
|
||||||
* - Right: booking summary
|
|
||||||
*/
|
|
||||||
|
|
||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { useRouter, useParams } from 'next/navigation';
|
|
||||||
import {
|
|
||||||
CreditCard,
|
|
||||||
Building2,
|
|
||||||
ArrowLeft,
|
|
||||||
Loader2,
|
|
||||||
AlertTriangle,
|
|
||||||
CheckCircle,
|
|
||||||
Copy,
|
|
||||||
Clock,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { getCsvBooking, payBookingCommission, declareBankTransfer } from '@/lib/api/bookings';
|
|
||||||
|
|
||||||
interface BookingData {
|
|
||||||
id: string;
|
|
||||||
bookingNumber?: string;
|
|
||||||
carrierName: string;
|
|
||||||
carrierEmail: string;
|
|
||||||
origin: string;
|
|
||||||
destination: string;
|
|
||||||
volumeCBM: number;
|
|
||||||
weightKG: number;
|
|
||||||
palletCount: number;
|
|
||||||
priceEUR: number;
|
|
||||||
priceUSD: number;
|
|
||||||
primaryCurrency: string;
|
|
||||||
transitDays: number;
|
|
||||||
containerType: string;
|
|
||||||
status: string;
|
|
||||||
commissionRate?: number;
|
|
||||||
commissionAmountEur?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
type PaymentMethod = 'card' | 'transfer' | null;
|
|
||||||
|
|
||||||
const BANK_DETAILS = {
|
|
||||||
beneficiary: 'XPEDITIS SAS',
|
|
||||||
iban: 'FR76 XXXX XXXX XXXX XXXX XXXX XXX',
|
|
||||||
bic: 'XXXXXXXX',
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function PayCommissionPage() {
|
|
||||||
const router = useRouter();
|
|
||||||
const params = useParams();
|
|
||||||
const bookingId = params.id as string;
|
|
||||||
|
|
||||||
const [booking, setBooking] = useState<BookingData | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [paying, setPaying] = useState(false);
|
|
||||||
const [declaring, setDeclaring] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [selectedMethod, setSelectedMethod] = useState<PaymentMethod>(null);
|
|
||||||
const [copied, setCopied] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
async function fetchBooking() {
|
|
||||||
try {
|
|
||||||
const data = await getCsvBooking(bookingId);
|
|
||||||
setBooking(data as any);
|
|
||||||
if (data.status !== 'PENDING_PAYMENT') {
|
|
||||||
router.replace('/dashboard/bookings');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setError('Impossible de charger les détails du booking');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (bookingId) fetchBooking();
|
|
||||||
}, [bookingId, router]);
|
|
||||||
|
|
||||||
const handlePayByCard = async () => {
|
|
||||||
setPaying(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const result = await payBookingCommission(bookingId);
|
|
||||||
window.location.href = result.sessionUrl;
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Erreur lors de la création du paiement');
|
|
||||||
setPaying(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeclareTransfer = async () => {
|
|
||||||
setDeclaring(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
await declareBankTransfer(bookingId);
|
|
||||||
router.push('/dashboard/bookings?transfer=declared');
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Erreur lors de la déclaration du virement');
|
|
||||||
setDeclaring(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const copyToClipboard = (value: string, key: string) => {
|
|
||||||
navigator.clipboard.writeText(value);
|
|
||||||
setCopied(key);
|
|
||||||
setTimeout(() => setCopied(null), 2000);
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatPrice = (price: number, currency: string) =>
|
|
||||||
new Intl.NumberFormat('fr-FR', { style: 'currency', currency }).format(price);
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-blue-50">
|
|
||||||
<div className="flex items-center space-x-3">
|
|
||||||
<Loader2 className="h-6 w-6 animate-spin text-blue-600" />
|
|
||||||
<span className="text-gray-600">Chargement...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error && !booking) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-blue-50">
|
|
||||||
<div className="bg-white rounded-xl shadow-md p-8 max-w-md">
|
|
||||||
<AlertTriangle className="h-12 w-12 text-red-500 mx-auto mb-4" />
|
|
||||||
<p className="text-center text-gray-700">{error}</p>
|
|
||||||
<button
|
|
||||||
onClick={() => router.push('/dashboard/bookings')}
|
|
||||||
className="mt-4 w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
|
||||||
>
|
|
||||||
Retour aux bookings
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!booking) return null;
|
|
||||||
|
|
||||||
const commissionAmount = booking.commissionAmountEur || 0;
|
|
||||||
const commissionRate = booking.commissionRate || 0;
|
|
||||||
const reference = booking.bookingNumber || booking.id.slice(0, 8).toUpperCase();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-blue-50 py-10 px-4">
|
|
||||||
<div className="max-w-5xl mx-auto">
|
|
||||||
{/* Back button */}
|
|
||||||
<button
|
|
||||||
onClick={() => router.push('/dashboard/bookings')}
|
|
||||||
className="mb-6 flex items-center text-blue-600 hover:text-blue-800 font-medium"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
|
||||||
Retour aux bookings
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<h1 className="text-2xl font-bold text-gray-900 mb-1">Paiement de la commission</h1>
|
|
||||||
<p className="text-gray-500 mb-8">
|
|
||||||
Finalisez votre booking en réglant la commission de service
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="mb-6 bg-red-50 border border-red-200 rounded-lg p-4 flex items-start space-x-3">
|
|
||||||
<AlertTriangle className="h-5 w-5 text-red-500 flex-shrink-0 mt-0.5" />
|
|
||||||
<p className="text-red-700 text-sm">{error}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
|
|
||||||
{/* LEFT — Payment method selector */}
|
|
||||||
<div className="lg:col-span-3 space-y-4">
|
|
||||||
<h2 className="text-base font-semibold text-gray-700 uppercase tracking-wide">
|
|
||||||
Choisir le mode de paiement
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
{/* Card option */}
|
|
||||||
<button
|
|
||||||
onClick={() => setSelectedMethod('card')}
|
|
||||||
className={`w-full text-left rounded-xl border-2 p-5 transition-all ${
|
|
||||||
selectedMethod === 'card'
|
|
||||||
? 'border-blue-500 bg-blue-50'
|
|
||||||
: 'border-gray-200 bg-white hover:border-gray-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center space-x-3">
|
|
||||||
<div
|
|
||||||
className={`w-10 h-10 rounded-full flex items-center justify-center ${
|
|
||||||
selectedMethod === 'card' ? 'bg-blue-100' : 'bg-gray-100'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<CreditCard
|
|
||||||
className={`h-5 w-5 ${
|
|
||||||
selectedMethod === 'card' ? 'text-blue-600' : 'text-gray-500'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="font-semibold text-gray-900">Carte bancaire</p>
|
|
||||||
<p className="text-sm text-gray-500">Paiement immédiat via Stripe</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${
|
|
||||||
selectedMethod === 'card' ? 'border-blue-500 bg-blue-500' : 'border-gray-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{selectedMethod === 'card' && (
|
|
||||||
<div className="w-2 h-2 rounded-full bg-white" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Transfer option */}
|
|
||||||
<button
|
|
||||||
onClick={() => setSelectedMethod('transfer')}
|
|
||||||
className={`w-full text-left rounded-xl border-2 p-5 transition-all ${
|
|
||||||
selectedMethod === 'transfer'
|
|
||||||
? 'border-blue-500 bg-blue-50'
|
|
||||||
: 'border-gray-200 bg-white hover:border-gray-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center space-x-3">
|
|
||||||
<div
|
|
||||||
className={`w-10 h-10 rounded-full flex items-center justify-center ${
|
|
||||||
selectedMethod === 'transfer' ? 'bg-blue-100' : 'bg-gray-100'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Building2
|
|
||||||
className={`h-5 w-5 ${
|
|
||||||
selectedMethod === 'transfer' ? 'text-blue-600' : 'text-gray-500'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="font-semibold text-gray-900">Virement bancaire</p>
|
|
||||||
<p className="text-sm text-gray-500">Validation sous 1–3 jours ouvrables</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${
|
|
||||||
selectedMethod === 'transfer'
|
|
||||||
? 'border-blue-500 bg-blue-500'
|
|
||||||
: 'border-gray-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{selectedMethod === 'transfer' && (
|
|
||||||
<div className="w-2 h-2 rounded-full bg-white" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Card action */}
|
|
||||||
{selectedMethod === 'card' && (
|
|
||||||
<div className="bg-white rounded-xl border border-gray-200 p-5">
|
|
||||||
<p className="text-sm text-gray-600 mb-4">
|
|
||||||
Vous serez redirigé vers Stripe pour finaliser votre paiement en toute sécurité.
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
onClick={handlePayByCard}
|
|
||||||
disabled={paying}
|
|
||||||
className="w-full py-3 bg-blue-600 text-white rounded-lg font-semibold hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed flex items-center justify-center space-x-2 transition-colors"
|
|
||||||
>
|
|
||||||
{paying ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="h-5 w-5 animate-spin" />
|
|
||||||
<span>Redirection vers Stripe...</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<CreditCard className="h-5 w-5" />
|
|
||||||
<span>Payer {formatPrice(commissionAmount, 'EUR')} par carte</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Transfer action */}
|
|
||||||
{selectedMethod === 'transfer' && (
|
|
||||||
<div className="bg-white rounded-xl border border-gray-200 p-5 space-y-4">
|
|
||||||
<p className="text-sm text-gray-600">
|
|
||||||
Effectuez le virement avec les coordonnées ci-dessous, puis cliquez sur
|
|
||||||
“J'ai effectué le virement”.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Bank details */}
|
|
||||||
<div className="bg-gray-50 rounded-lg divide-y divide-gray-200 text-sm">
|
|
||||||
{[
|
|
||||||
{ label: 'Bénéficiaire', value: BANK_DETAILS.beneficiary, key: 'beneficiary' },
|
|
||||||
{ label: 'IBAN', value: BANK_DETAILS.iban, key: 'iban', mono: true },
|
|
||||||
{ label: 'BIC / SWIFT', value: BANK_DETAILS.bic, key: 'bic', mono: true },
|
|
||||||
{
|
|
||||||
label: 'Montant',
|
|
||||||
value: formatPrice(commissionAmount, 'EUR'),
|
|
||||||
key: 'amount',
|
|
||||||
bold: true,
|
|
||||||
},
|
|
||||||
{ label: 'Référence', value: reference, key: 'ref', mono: true },
|
|
||||||
].map(({ label, value, key, mono, bold }) => (
|
|
||||||
<div key={key} className="flex items-center justify-between px-4 py-3">
|
|
||||||
<span className="text-gray-500">{label}</span>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<span
|
|
||||||
className={`${mono ? 'font-mono' : ''} ${bold ? 'font-bold text-gray-900' : 'text-gray-800'}`}
|
|
||||||
>
|
|
||||||
{value}
|
|
||||||
</span>
|
|
||||||
{key !== 'amount' && (
|
|
||||||
<button
|
|
||||||
onClick={() => copyToClipboard(value, key)}
|
|
||||||
className="text-gray-400 hover:text-blue-600 transition-colors"
|
|
||||||
title="Copier"
|
|
||||||
>
|
|
||||||
{copied === key ? (
|
|
||||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
|
||||||
) : (
|
|
||||||
<Copy className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-start space-x-2 text-xs text-amber-700 bg-amber-50 rounded-lg p-3">
|
|
||||||
<Clock className="h-4 w-4 flex-shrink-0 mt-0.5" />
|
|
||||||
<span>
|
|
||||||
Mentionnez impérativement la référence <strong>{reference}</strong> dans le
|
|
||||||
libellé du virement.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={handleDeclareTransfer}
|
|
||||||
disabled={declaring}
|
|
||||||
className="w-full py-3 bg-green-600 text-white rounded-lg font-semibold hover:bg-green-700 disabled:bg-gray-400 disabled:cursor-not-allowed flex items-center justify-center space-x-2 transition-colors"
|
|
||||||
>
|
|
||||||
{declaring ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="h-5 w-5 animate-spin" />
|
|
||||||
<span>Enregistrement...</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<CheckCircle className="h-5 w-5" />
|
|
||||||
<span>J'ai effectué le virement</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Placeholder when no method selected */}
|
|
||||||
{selectedMethod === null && (
|
|
||||||
<div className="bg-white rounded-xl border-2 border-dashed border-gray-200 p-6 text-center text-gray-400 text-sm">
|
|
||||||
Sélectionnez un mode de paiement ci-dessus
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* RIGHT — Booking summary */}
|
|
||||||
<div className="lg:col-span-2 space-y-4">
|
|
||||||
<h2 className="text-base font-semibold text-gray-700 uppercase tracking-wide">
|
|
||||||
Récapitulatif
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div className="bg-white rounded-xl border border-gray-200 p-5 space-y-4">
|
|
||||||
{booking.bookingNumber && (
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-gray-500">Numéro</span>
|
|
||||||
<span className="font-semibold text-gray-900">{booking.bookingNumber}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-gray-500">Transporteur</span>
|
|
||||||
<span className="font-semibold text-gray-900 text-right max-w-[55%]">
|
|
||||||
{booking.carrierName}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-gray-500">Trajet</span>
|
|
||||||
<span className="font-semibold text-gray-900">
|
|
||||||
{booking.origin} → {booking.destination}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-gray-500">Volume / Poids</span>
|
|
||||||
<span className="font-semibold text-gray-900">
|
|
||||||
{booking.volumeCBM} CBM · {booking.weightKG} kg
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-gray-500">Transit</span>
|
|
||||||
<span className="font-semibold text-gray-900">{booking.transitDays} jours</span>
|
|
||||||
</div>
|
|
||||||
<div className="border-t pt-3 flex justify-between text-sm">
|
|
||||||
<span className="text-gray-500">Prix transport</span>
|
|
||||||
<span className="font-bold text-gray-900">
|
|
||||||
{formatPrice(booking.priceEUR, 'EUR')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Commission box */}
|
|
||||||
<div className="bg-blue-600 rounded-xl p-5 text-white">
|
|
||||||
<p className="text-sm text-blue-100 mb-1">
|
|
||||||
Commission ({commissionRate}% du prix transport)
|
|
||||||
</p>
|
|
||||||
<p className="text-3xl font-bold">{formatPrice(commissionAmount, 'EUR')}</p>
|
|
||||||
<p className="text-xs text-blue-200 mt-1">
|
|
||||||
{formatPrice(booking.priceEUR, 'EUR')} × {commissionRate}%
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white rounded-xl border border-gray-200 p-4 flex items-start space-x-3">
|
|
||||||
<CheckCircle className="h-4 w-4 text-green-500 mt-0.5 flex-shrink-0" />
|
|
||||||
<p className="text-xs text-gray-500">
|
|
||||||
Après validation du paiement, votre demande est envoyée au transporteur (
|
|
||||||
{booking.carrierEmail}). Vous serez notifié de sa réponse.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,147 +0,0 @@
|
|||||||
/**
|
|
||||||
* Payment Success Page
|
|
||||||
*
|
|
||||||
* Displayed after successful Stripe payment. Confirms the payment and activates the booking.
|
|
||||||
*/
|
|
||||||
|
|
||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useEffect, useRef } from 'react';
|
|
||||||
import { useRouter, useParams, useSearchParams } from 'next/navigation';
|
|
||||||
import { CheckCircle, Loader2, AlertTriangle, Mail, ArrowRight } from 'lucide-react';
|
|
||||||
import { confirmBookingPayment } from '@/lib/api/bookings';
|
|
||||||
|
|
||||||
export default function PaymentSuccessPage() {
|
|
||||||
const router = useRouter();
|
|
||||||
const params = useParams();
|
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const bookingId = params.id as string;
|
|
||||||
const sessionId = searchParams.get('session_id');
|
|
||||||
|
|
||||||
const [status, setStatus] = useState<'confirming' | 'success' | 'error'>('confirming');
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const confirmedRef = useRef(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
async function confirm() {
|
|
||||||
if (!sessionId || !bookingId || confirmedRef.current) return;
|
|
||||||
confirmedRef.current = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await confirmBookingPayment(bookingId, sessionId);
|
|
||||||
setStatus('success');
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Payment confirmation error:', err);
|
|
||||||
setError(
|
|
||||||
err instanceof Error ? err.message : 'Erreur lors de la confirmation du paiement'
|
|
||||||
);
|
|
||||||
setStatus('error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
confirm();
|
|
||||||
}, [bookingId, sessionId]);
|
|
||||||
|
|
||||||
if (!sessionId) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100">
|
|
||||||
<div className="bg-white rounded-lg shadow-md p-8 max-w-md text-center">
|
|
||||||
<AlertTriangle className="h-12 w-12 text-red-500 mx-auto mb-4" />
|
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-2">Session invalide</h2>
|
|
||||||
<p className="text-gray-600 mb-4">Aucune session de paiement trouvee.</p>
|
|
||||||
<button
|
|
||||||
onClick={() => router.push('/dashboard/bookings')}
|
|
||||||
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
|
||||||
>
|
|
||||||
Retour aux bookings
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 px-4">
|
|
||||||
<div className="bg-white rounded-lg shadow-lg p-8 max-w-md w-full text-center">
|
|
||||||
{status === 'confirming' && (
|
|
||||||
<>
|
|
||||||
<Loader2 className="h-16 w-16 animate-spin text-blue-600 mx-auto mb-6" />
|
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-2">Confirmation du paiement...</h2>
|
|
||||||
<p className="text-gray-600">
|
|
||||||
Veuillez patienter pendant que nous verifions votre paiement et activons votre booking.
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{status === 'success' && (
|
|
||||||
<>
|
|
||||||
<div className="mb-6">
|
|
||||||
<div className="w-20 h-20 bg-green-100 rounded-full flex items-center justify-center mx-auto">
|
|
||||||
<CheckCircle className="h-12 w-12 text-green-600" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-3">Paiement confirme !</h2>
|
|
||||||
<p className="text-gray-600 mb-6">
|
|
||||||
Votre commission a ete payee avec succes. Un email a ete envoye au transporteur avec votre demande de booking.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
|
|
||||||
<div className="flex items-center justify-center space-x-2 text-blue-700">
|
|
||||||
<Mail className="h-5 w-5" />
|
|
||||||
<span className="text-sm font-medium">
|
|
||||||
Email envoye au transporteur
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-blue-600 mt-1">
|
|
||||||
Vous recevrez une notification des que le transporteur repond (sous 7 jours max)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => router.push('/dashboard/bookings')}
|
|
||||||
className="w-full px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-semibold flex items-center justify-center"
|
|
||||||
>
|
|
||||||
Voir mes bookings
|
|
||||||
<ArrowRight className="h-4 w-4 ml-2" />
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{status === 'error' && (
|
|
||||||
<>
|
|
||||||
<AlertTriangle className="h-16 w-16 text-red-500 mx-auto mb-6" />
|
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-2">Erreur de confirmation</h2>
|
|
||||||
<p className="text-gray-600 mb-2">{error}</p>
|
|
||||||
<p className="text-sm text-gray-500 mb-6">
|
|
||||||
Si votre paiement a ete debite, contactez le support. Votre booking sera active manuellement.
|
|
||||||
</p>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
confirmedRef.current = false;
|
|
||||||
setStatus('confirming');
|
|
||||||
setError(null);
|
|
||||||
confirmBookingPayment(bookingId, sessionId!)
|
|
||||||
.then(() => setStatus('success'))
|
|
||||||
.catch(err => {
|
|
||||||
setError(err instanceof Error ? err.message : 'Erreur');
|
|
||||||
setStatus('error');
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
className="w-full px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
|
||||||
>
|
|
||||||
Reessayer
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => router.push('/dashboard/bookings')}
|
|
||||||
className="w-full px-6 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300"
|
|
||||||
>
|
|
||||||
Retour aux bookings
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -177,8 +177,8 @@ function NewBookingPageContent() {
|
|||||||
// Send to API using client function
|
// Send to API using client function
|
||||||
const result = await createCsvBooking(formDataToSend);
|
const result = await createCsvBooking(formDataToSend);
|
||||||
|
|
||||||
// Redirect to commission payment page
|
// Redirect to success page
|
||||||
router.push(`/dashboard/booking/${result.id}/pay`);
|
router.push(`/dashboard/bookings?success=true&id=${result.id}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Booking creation error:', err);
|
console.error('Booking creation error:', err);
|
||||||
setError(err instanceof Error ? err.message : 'Une erreur est survenue');
|
setError(err instanceof Error ? err.message : 'Une erreur est survenue');
|
||||||
|
|||||||
@ -6,31 +6,22 @@
|
|||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { listBookings, listCsvBookings } from '@/lib/api';
|
import { listBookings, listCsvBookings } from '@/lib/api';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Plus, Clock } from 'lucide-react';
|
import { Plus } from 'lucide-react';
|
||||||
import ExportButton from '@/components/ExportButton';
|
import ExportButton from '@/components/ExportButton';
|
||||||
import { useSearchParams } from 'next/navigation';
|
|
||||||
|
|
||||||
type SearchType = 'pallets' | 'weight' | 'route' | 'status' | 'date' | 'quote';
|
type SearchType = 'pallets' | 'weight' | 'route' | 'status' | 'date' | 'quote';
|
||||||
|
|
||||||
export default function BookingsListPage() {
|
export default function BookingsListPage() {
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [searchType, setSearchType] = useState<SearchType>('route');
|
const [searchType, setSearchType] = useState<SearchType>('route');
|
||||||
const [statusFilter, setStatusFilter] = useState('');
|
const [statusFilter, setStatusFilter] = useState('');
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [showTransferBanner, setShowTransferBanner] = useState(false);
|
|
||||||
const ITEMS_PER_PAGE = 20;
|
const ITEMS_PER_PAGE = 20;
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (searchParams.get('transfer') === 'declared') {
|
|
||||||
setShowTransferBanner(true);
|
|
||||||
}
|
|
||||||
}, [searchParams]);
|
|
||||||
|
|
||||||
// Fetch CSV bookings (fetch all for client-side filtering and pagination)
|
// Fetch CSV bookings (fetch all for client-side filtering and pagination)
|
||||||
const { data: csvData, isLoading, error: csvError } = useQuery({
|
const { data: csvData, isLoading, error: csvError } = useQuery({
|
||||||
queryKey: ['csv-bookings'],
|
queryKey: ['csv-bookings'],
|
||||||
@ -151,21 +142,6 @@ export default function BookingsListPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Bank transfer declared banner */}
|
|
||||||
{showTransferBanner && (
|
|
||||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 flex items-start justify-between">
|
|
||||||
<div className="flex items-start space-x-3">
|
|
||||||
<Clock className="h-5 w-5 text-amber-600 flex-shrink-0 mt-0.5" />
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-amber-800">Virement déclaré</p>
|
|
||||||
<p className="text-sm text-amber-700 mt-0.5">
|
|
||||||
Votre virement a été enregistré. Un administrateur va vérifier la réception et activer votre booking. Vous serez notifié dès la validation.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button onClick={() => setShowTransferBanner(false)} className="text-amber-500 hover:text-amber-700 ml-4 flex-shrink-0">✕</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@ -22,15 +22,10 @@ import {
|
|||||||
Building2,
|
Building2,
|
||||||
Users,
|
Users,
|
||||||
LogOut,
|
LogOut,
|
||||||
Lock,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useSubscription } from '@/lib/context/subscription-context';
|
|
||||||
import StatusBadge from '@/components/ui/StatusBadge';
|
|
||||||
import type { PlanFeature } from '@/lib/api/subscriptions';
|
|
||||||
|
|
||||||
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
|
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||||
const { user, logout, loading, isAuthenticated } = useAuth();
|
const { user, logout, loading, isAuthenticated } = useAuth();
|
||||||
const { hasFeature, subscription } = useSubscription();
|
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
@ -53,16 +48,16 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const navigation: Array<{ name: string; href: string; icon: any; requiredFeature?: PlanFeature }> = [
|
const navigation = [
|
||||||
{ name: 'Tableau de bord', href: '/dashboard', icon: BarChart3, requiredFeature: 'dashboard' },
|
{ name: 'Tableau de bord', href: '/dashboard', icon: BarChart3 },
|
||||||
{ name: 'Réservations', href: '/dashboard/bookings', icon: Package },
|
{ name: 'Réservations', href: '/dashboard/bookings', icon: Package },
|
||||||
{ name: 'Documents', href: '/dashboard/documents', icon: FileText },
|
{ name: 'Documents', href: '/dashboard/documents', icon: FileText },
|
||||||
{ name: 'Suivi', href: '/dashboard/track-trace', icon: Search, requiredFeature: 'dashboard' },
|
{ name: 'Suivi', href: '/dashboard/track-trace', icon: Search },
|
||||||
{ name: 'Wiki Maritime', href: '/dashboard/wiki', icon: BookOpen, requiredFeature: 'wiki' },
|
{ name: 'Wiki Maritime', href: '/dashboard/wiki', icon: BookOpen },
|
||||||
{ name: 'Organisation', href: '/dashboard/settings/organization', icon: Building2 },
|
{ name: 'Organisation', href: '/dashboard/settings/organization', icon: Building2 },
|
||||||
// ADMIN and MANAGER only navigation items
|
// ADMIN and MANAGER only navigation items
|
||||||
...(user?.role === 'ADMIN' || user?.role === 'MANAGER' ? [
|
...(user?.role === 'ADMIN' || user?.role === 'MANAGER' ? [
|
||||||
{ name: 'Utilisateurs', href: '/dashboard/settings/users', icon: Users, requiredFeature: 'user_management' as PlanFeature },
|
{ name: 'Utilisateurs', href: '/dashboard/settings/users', icon: Users },
|
||||||
] : []),
|
] : []),
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -119,26 +114,20 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
|||||||
|
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
<nav className="flex-1 px-4 py-6 space-y-2 overflow-y-auto">
|
<nav className="flex-1 px-4 py-6 space-y-2 overflow-y-auto">
|
||||||
{navigation.map(item => {
|
{navigation.map(item => (
|
||||||
const locked = item.requiredFeature && !hasFeature(item.requiredFeature);
|
<Link
|
||||||
return (
|
key={item.name}
|
||||||
<Link
|
href={item.href}
|
||||||
key={item.name}
|
className={`flex items-center px-4 py-3 text-sm font-medium rounded-lg transition-colors ${
|
||||||
href={locked ? '/pricing' : item.href}
|
isActive(item.href)
|
||||||
className={`flex items-center px-4 py-3 text-sm font-medium rounded-lg transition-colors ${
|
? 'bg-blue-50 text-blue-700'
|
||||||
locked
|
: 'text-gray-700 hover:bg-gray-100'
|
||||||
? 'text-gray-400 hover:bg-gray-50'
|
}`}
|
||||||
: isActive(item.href)
|
>
|
||||||
? 'bg-blue-50 text-blue-700'
|
<item.icon className="mr-3 h-5 w-5" />
|
||||||
: 'text-gray-700 hover:bg-gray-100'
|
{item.name}
|
||||||
}`}
|
</Link>
|
||||||
>
|
))}
|
||||||
<item.icon className="mr-3 h-5 w-5" />
|
|
||||||
<span className="flex-1">{item.name}</span>
|
|
||||||
{locked && <Lock className="w-4 h-4 text-gray-300" />}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* Admin Panel - ADMIN role only */}
|
{/* Admin Panel - ADMIN role only */}
|
||||||
{user?.role === 'ADMIN' && (
|
{user?.role === 'ADMIN' && (
|
||||||
@ -156,14 +145,9 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
|||||||
{user?.lastName?.[0]}
|
{user?.lastName?.[0]}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-1.5">
|
<p className="text-sm font-medium text-gray-900 truncate">
|
||||||
<p className="text-sm font-medium text-gray-900 truncate">
|
{user?.firstName} {user?.lastName}
|
||||||
{user?.firstName} {user?.lastName}
|
</p>
|
||||||
</p>
|
|
||||||
{subscription?.planDetails?.statusBadge && subscription.planDetails.statusBadge !== 'none' && (
|
|
||||||
<StatusBadge badge={subscription.planDetails.statusBadge} size="sm" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-gray-500 truncate">{user?.email}</p>
|
<p className="text-xs text-gray-500 truncate">{user?.email}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -5,14 +5,12 @@
|
|||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect } from 'react';
|
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { dashboardApi } from '@/lib/api';
|
import { dashboardApi } from '@/lib/api';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import {
|
import {
|
||||||
Package,
|
Package,
|
||||||
PackageCheck,
|
PackageCheck,
|
||||||
@ -23,7 +21,6 @@ import {
|
|||||||
Plus,
|
Plus,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useSubscription } from '@/lib/context/subscription-context';
|
|
||||||
import ExportButton from '@/components/ExportButton';
|
import ExportButton from '@/components/ExportButton';
|
||||||
import {
|
import {
|
||||||
PieChart,
|
PieChart,
|
||||||
@ -42,16 +39,6 @@ import {
|
|||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const router = useRouter();
|
|
||||||
const { hasFeature, loading: subLoading } = useSubscription();
|
|
||||||
|
|
||||||
// Redirect Bronze users (no dashboard feature) to bookings
|
|
||||||
useEffect(() => {
|
|
||||||
if (!subLoading && !hasFeature('dashboard')) {
|
|
||||||
router.replace('/dashboard/bookings');
|
|
||||||
}
|
|
||||||
}, [subLoading, hasFeature, router]);
|
|
||||||
|
|
||||||
// Fetch CSV booking KPIs
|
// Fetch CSV booking KPIs
|
||||||
const { data: csvKpis, isLoading: csvKpisLoading } = useQuery({
|
const { data: csvKpis, isLoading: csvKpisLoading } = useQuery({
|
||||||
queryKey: ['dashboard', 'csv-booking-kpis'],
|
queryKey: ['dashboard', 'csv-booking-kpis'],
|
||||||
|
|||||||
@ -85,8 +85,6 @@ export default function LandingPage() {
|
|||||||
const isCtaInView = useInView(ctaRef, { once: true });
|
const isCtaInView = useInView(ctaRef, { once: true });
|
||||||
const isHowInView = useInView(howRef, { once: true, amount: 0.2 });
|
const isHowInView = useInView(howRef, { once: true, amount: 0.2 });
|
||||||
|
|
||||||
const [billingYearly, setBillingYearly] = useState(false);
|
|
||||||
|
|
||||||
const { scrollYProgress } = useScroll();
|
const { scrollYProgress } = useScroll();
|
||||||
const backgroundY = useTransform(scrollYProgress, [0, 1], ['0%', '50%']);
|
const backgroundY = useTransform(scrollYProgress, [0, 1], ['0%', '50%']);
|
||||||
|
|
||||||
@ -187,120 +185,58 @@ export default function LandingPage() {
|
|||||||
|
|
||||||
const pricingPlans = [
|
const pricingPlans = [
|
||||||
{
|
{
|
||||||
key: 'bronze',
|
name: 'Starter',
|
||||||
name: 'Bronze',
|
price: 'Gratuit',
|
||||||
badge: null,
|
period: '',
|
||||||
monthlyPrice: 0,
|
description: 'Idéal pour découvrir la plateforme',
|
||||||
yearlyPrice: 0,
|
|
||||||
yearlyMonthly: 0,
|
|
||||||
description: 'Pour découvrir la plateforme',
|
|
||||||
users: '1 utilisateur',
|
|
||||||
shipments: '12 expéditions / an',
|
|
||||||
commission: '5%',
|
|
||||||
support: 'Aucun support',
|
|
||||||
features: [
|
features: [
|
||||||
{ text: 'Réservations maritimes LCL', included: true },
|
{ text: 'Jusqu\'à 5 bookings/mois', included: true },
|
||||||
{ text: 'Track & Trace conteneurs', included: true },
|
{ text: 'Track & Trace illimité', included: true },
|
||||||
{ text: 'Tableau de bord', included: false },
|
{ text: 'Wiki maritime complet', included: true },
|
||||||
{ text: 'Wiki maritime', included: false },
|
{ text: 'Dashboard basique', included: true },
|
||||||
{ text: 'Gestion des utilisateurs', included: false },
|
{ text: 'Support par email', included: true },
|
||||||
{ text: 'Export CSV', included: false },
|
{ text: 'Gestion des documents', included: false },
|
||||||
|
{ text: 'Notifications temps réel', included: false },
|
||||||
{ text: 'Accès API', included: false },
|
{ text: 'Accès API', included: false },
|
||||||
{ text: 'KAM dédié', included: false },
|
|
||||||
],
|
],
|
||||||
cta: 'Commencer gratuitement',
|
cta: 'Commencer gratuitement',
|
||||||
ctaLink: '/register',
|
|
||||||
highlighted: false,
|
highlighted: false,
|
||||||
accentColor: 'from-amber-600 to-yellow-500',
|
|
||||||
textAccent: 'text-amber-700',
|
|
||||||
badgeBg: 'bg-amber-100 text-amber-800',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'silver',
|
name: 'Professional',
|
||||||
name: 'Silver',
|
price: '99€',
|
||||||
badge: 'Populaire',
|
period: '/mois',
|
||||||
monthlyPrice: 249,
|
|
||||||
yearlyPrice: 2739,
|
|
||||||
yearlyMonthly: 228,
|
|
||||||
description: 'Pour les transitaires en croissance',
|
description: 'Pour les transitaires en croissance',
|
||||||
users: 'Jusqu\'à 5 utilisateurs',
|
|
||||||
shipments: 'Expéditions illimitées',
|
|
||||||
commission: '3%',
|
|
||||||
support: 'Support par email',
|
|
||||||
features: [
|
features: [
|
||||||
{ text: 'Réservations maritimes LCL', included: true },
|
{ text: 'Bookings illimités', included: true },
|
||||||
{ text: 'Track & Trace conteneurs', included: true },
|
{ text: 'Track & Trace illimité', included: true },
|
||||||
{ text: 'Tableau de bord avancé', included: true },
|
|
||||||
{ text: 'Wiki maritime complet', included: true },
|
{ text: 'Wiki maritime complet', included: true },
|
||||||
{ text: 'Gestion des utilisateurs', included: true },
|
{ text: 'Dashboard avancé + KPIs', included: true },
|
||||||
{ text: 'Export CSV', included: true },
|
{ text: 'Support prioritaire', included: true },
|
||||||
|
{ text: 'Gestion des documents', included: true },
|
||||||
|
{ text: 'Notifications temps réel', included: true },
|
||||||
{ text: 'Accès API', included: false },
|
{ text: 'Accès API', included: false },
|
||||||
{ text: 'KAM dédié', included: false },
|
|
||||||
],
|
],
|
||||||
cta: 'Essai gratuit 14 jours',
|
cta: 'Essai gratuit 14 jours',
|
||||||
ctaLink: '/register',
|
|
||||||
highlighted: true,
|
highlighted: true,
|
||||||
accentColor: 'from-slate-400 to-slate-500',
|
|
||||||
textAccent: 'text-slate-600',
|
|
||||||
badgeBg: 'bg-slate-100 text-slate-700',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'gold',
|
name: 'Enterprise',
|
||||||
name: 'Gold',
|
price: 'Sur mesure',
|
||||||
badge: null,
|
period: '',
|
||||||
monthlyPrice: 899,
|
|
||||||
yearlyPrice: 9889,
|
|
||||||
yearlyMonthly: 824,
|
|
||||||
description: 'Pour les équipes exigeantes',
|
|
||||||
users: 'Jusqu\'à 20 utilisateurs',
|
|
||||||
shipments: 'Expéditions illimitées',
|
|
||||||
commission: '2%',
|
|
||||||
support: 'Assistance commerciale directe',
|
|
||||||
features: [
|
|
||||||
{ text: 'Réservations maritimes LCL', included: true },
|
|
||||||
{ text: 'Track & Trace conteneurs', included: true },
|
|
||||||
{ text: 'Tableau de bord avancé', included: true },
|
|
||||||
{ text: 'Wiki maritime complet', included: true },
|
|
||||||
{ text: 'Gestion des utilisateurs', included: true },
|
|
||||||
{ text: 'Export CSV', included: true },
|
|
||||||
{ text: 'Accès API complet', included: true },
|
|
||||||
{ text: 'KAM dédié', included: false },
|
|
||||||
],
|
|
||||||
cta: 'Essai gratuit 14 jours',
|
|
||||||
ctaLink: '/register',
|
|
||||||
highlighted: false,
|
|
||||||
accentColor: 'from-yellow-400 to-amber-400',
|
|
||||||
textAccent: 'text-amber-600',
|
|
||||||
badgeBg: 'bg-yellow-50 text-amber-700',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'platinium',
|
|
||||||
name: 'Platinium',
|
|
||||||
badge: 'Sur mesure',
|
|
||||||
monthlyPrice: null,
|
|
||||||
yearlyPrice: null,
|
|
||||||
yearlyMonthly: null,
|
|
||||||
description: 'Pour les grandes entreprises',
|
description: 'Pour les grandes entreprises',
|
||||||
users: 'Utilisateurs illimités',
|
|
||||||
shipments: 'Expéditions illimitées',
|
|
||||||
commission: '1%',
|
|
||||||
support: 'Key Account Manager dédié',
|
|
||||||
features: [
|
features: [
|
||||||
{ text: 'Réservations maritimes LCL', included: true },
|
{ text: 'Tout Professionnel +', included: true },
|
||||||
{ text: 'Track & Trace conteneurs', included: true },
|
|
||||||
{ text: 'Tableau de bord avancé', included: true },
|
|
||||||
{ text: 'Wiki maritime complet', included: true },
|
|
||||||
{ text: 'Gestion des utilisateurs', included: true },
|
|
||||||
{ text: 'Export CSV', included: true },
|
|
||||||
{ text: 'Accès API complet', included: true },
|
{ text: 'Accès API complet', included: true },
|
||||||
{ text: 'KAM dédié + Interface personnalisée', included: true },
|
{ text: 'Intégrations personnalisées', included: true },
|
||||||
|
{ text: 'Responsable de compte dédié', included: true },
|
||||||
|
{ text: 'SLA garanti 99.9%', included: true },
|
||||||
|
{ text: 'Formation sur site', included: true },
|
||||||
|
{ text: 'Multi-organisations', included: true },
|
||||||
|
{ text: 'Audit & conformité', included: true },
|
||||||
],
|
],
|
||||||
cta: 'Nous contacter',
|
cta: 'Contactez-nous',
|
||||||
ctaLink: '/contact',
|
|
||||||
highlighted: false,
|
highlighted: false,
|
||||||
accentColor: 'from-brand-navy to-brand-turquoise',
|
|
||||||
textAccent: 'text-brand-turquoise',
|
|
||||||
badgeBg: 'bg-brand-navy/10 text-brand-navy',
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -719,198 +655,76 @@ export default function LandingPage() {
|
|||||||
<section
|
<section
|
||||||
ref={pricingRef}
|
ref={pricingRef}
|
||||||
id="pricing"
|
id="pricing"
|
||||||
className="py-20 lg:py-32 bg-gradient-to-b from-white to-gray-50"
|
className="py-20 lg:py-32 bg-gradient-to-br from-gray-50 to-white"
|
||||||
>
|
>
|
||||||
<div className="max-w-7xl mx-auto px-6 lg:px-8">
|
<div className="max-w-7xl mx-auto px-6 lg:px-8">
|
||||||
{/* Header */}
|
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 30 }}
|
initial={{ opacity: 0, y: 30 }}
|
||||||
animate={isPricingInView ? { opacity: 1, y: 0 } : {}}
|
animate={isPricingInView ? { opacity: 1, y: 0 } : {}}
|
||||||
transition={{ duration: 0.8 }}
|
transition={{ duration: 0.8 }}
|
||||||
className="text-center mb-12"
|
className="text-center mb-16"
|
||||||
>
|
>
|
||||||
<span className="inline-block bg-brand-turquoise/10 text-brand-turquoise text-sm font-semibold px-4 py-1.5 rounded-full mb-4 uppercase tracking-wide">
|
|
||||||
Tarifs
|
|
||||||
</span>
|
|
||||||
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">
|
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">
|
||||||
Des plans adaptés à votre activité
|
Tarifs simples et transparents
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
|
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
|
||||||
De l'accès découverte au partenariat sur mesure — évoluez à tout moment.
|
Choisissez le plan adapté à vos besoins. Évoluez à tout moment.
|
||||||
</p>
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Billing Toggle */}
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 10 }}
|
|
||||||
animate={isPricingInView ? { opacity: 1, y: 0 } : {}}
|
|
||||||
transition={{ duration: 0.6, delay: 0.2 }}
|
|
||||||
className="flex items-center justify-center gap-4 mb-12"
|
|
||||||
>
|
|
||||||
<span className={`text-sm font-medium ${!billingYearly ? 'text-brand-navy' : 'text-gray-400'}`}>
|
|
||||||
Mensuel
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={() => setBillingYearly(v => !v)}
|
|
||||||
className={`relative inline-flex h-7 w-14 items-center rounded-full transition-colors focus:outline-none ${
|
|
||||||
billingYearly ? 'bg-brand-turquoise' : 'bg-gray-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition-transform ${
|
|
||||||
billingYearly ? 'translate-x-8' : 'translate-x-1'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
<span className={`text-sm font-medium ${billingYearly ? 'text-brand-navy' : 'text-gray-400'}`}>
|
|
||||||
Annuel
|
|
||||||
</span>
|
|
||||||
{billingYearly && (
|
|
||||||
<span className="bg-brand-green/10 text-brand-green text-xs font-bold px-2.5 py-1 rounded-full">
|
|
||||||
1 mois offert
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Plans Grid */}
|
|
||||||
<motion.div
|
<motion.div
|
||||||
variants={containerVariants}
|
variants={containerVariants}
|
||||||
initial="hidden"
|
initial="hidden"
|
||||||
animate={isPricingInView ? 'visible' : 'hidden'}
|
animate={isPricingInView ? 'visible' : 'hidden'}
|
||||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 items-stretch"
|
className="grid grid-cols-1 md:grid-cols-3 gap-8"
|
||||||
>
|
>
|
||||||
{pricingPlans.map((plan, index) => (
|
{pricingPlans.map((plan, index) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={plan.key}
|
key={index}
|
||||||
variants={itemVariants}
|
variants={itemVariants}
|
||||||
whileHover={{ y: -6 }}
|
whileHover={{ y: -10 }}
|
||||||
className={`relative flex flex-col rounded-2xl transition-all overflow-hidden ${
|
className={`relative bg-white rounded-2xl shadow-lg border-2 transition-all ${
|
||||||
plan.highlighted
|
plan.highlighted
|
||||||
? 'bg-brand-navy shadow-2xl ring-2 ring-brand-turquoise'
|
? 'border-brand-turquoise shadow-2xl scale-105'
|
||||||
: 'bg-white shadow-lg border border-gray-100 hover:shadow-xl hover:border-brand-turquoise/30'
|
: 'border-gray-200 hover:border-brand-turquoise/50'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{/* Top gradient bar */}
|
{plan.highlighted && (
|
||||||
<div className={`h-1.5 w-full bg-gradient-to-r ${plan.accentColor}`} />
|
<div className="absolute -top-4 left-1/2 transform -translate-x-1/2">
|
||||||
|
<span className="bg-brand-turquoise text-white text-sm font-bold px-4 py-1 rounded-full">
|
||||||
{/* Popular badge */}
|
Populaire
|
||||||
{plan.badge && plan.key === 'silver' && (
|
|
||||||
<div className="absolute top-4 right-4">
|
|
||||||
<span className="bg-brand-turquoise text-white text-xs font-bold px-2.5 py-1 rounded-full">
|
|
||||||
{plan.badge}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{plan.badge && plan.key === 'platinium' && (
|
<div className="p-8">
|
||||||
<div className="absolute top-4 right-4">
|
<h3 className="text-2xl font-bold text-brand-navy mb-2">{plan.name}</h3>
|
||||||
<span className="bg-gradient-to-r from-brand-navy to-brand-turquoise text-white text-xs font-bold px-2.5 py-1 rounded-full">
|
<p className="text-gray-600 mb-6">{plan.description}</p>
|
||||||
{plan.badge}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex flex-col flex-1 p-6">
|
|
||||||
{/* Plan name */}
|
|
||||||
<div className="mb-4">
|
|
||||||
<div className={`inline-flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wider px-2.5 py-1 rounded-full mb-3 ${plan.highlighted ? 'bg-white/10 text-white/70' : plan.badgeBg}`}>
|
|
||||||
<div className={`w-1.5 h-1.5 rounded-full bg-gradient-to-r ${plan.accentColor}`} />
|
|
||||||
{plan.name}
|
|
||||||
</div>
|
|
||||||
<h3 className={`text-xl font-bold mb-1 ${plan.highlighted ? 'text-white' : 'text-brand-navy'}`}>
|
|
||||||
{plan.description}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Price */}
|
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
{plan.monthlyPrice === null ? (
|
<span className="text-5xl font-bold text-brand-navy">{plan.price}</span>
|
||||||
<div>
|
<span className="text-gray-500">{plan.period}</span>
|
||||||
<span className={`text-3xl font-bold ${plan.highlighted ? 'text-white' : 'text-brand-navy'}`}>
|
|
||||||
Sur mesure
|
|
||||||
</span>
|
|
||||||
<p className={`text-sm mt-1 ${plan.highlighted ? 'text-white/60' : 'text-gray-500'}`}>
|
|
||||||
Tarification personnalisée
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : plan.monthlyPrice === 0 ? (
|
|
||||||
<div>
|
|
||||||
<span className={`text-4xl font-bold ${plan.highlighted ? 'text-white' : 'text-brand-navy'}`}>
|
|
||||||
Gratuit
|
|
||||||
</span>
|
|
||||||
<p className={`text-sm mt-1 ${plan.highlighted ? 'text-white/60' : 'text-gray-500'}`}>
|
|
||||||
Pour toujours
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div>
|
|
||||||
<div className="flex items-end gap-1">
|
|
||||||
<span className={`text-4xl font-bold ${plan.highlighted ? 'text-white' : 'text-brand-navy'}`}>
|
|
||||||
{billingYearly ? plan.yearlyMonthly : plan.monthlyPrice}€
|
|
||||||
</span>
|
|
||||||
<span className={`text-sm pb-1.5 ${plan.highlighted ? 'text-white/60' : 'text-gray-500'}`}>
|
|
||||||
/mois
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{billingYearly ? (
|
|
||||||
<p className={`text-xs mt-1 ${plan.highlighted ? 'text-white/60' : 'text-gray-500'}`}>
|
|
||||||
Facturé {plan.yearlyPrice?.toLocaleString('fr-FR')}€/an
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<p className={`text-xs mt-1 ${plan.highlighted ? 'text-brand-turquoise' : 'text-brand-turquoise'}`}>
|
|
||||||
Économisez 1 mois avec l'annuel
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
<ul className="space-y-3 mb-8">
|
||||||
{/* Key stats */}
|
|
||||||
<div className={`rounded-xl p-3 mb-5 space-y-2 ${plan.highlighted ? 'bg-white/10' : 'bg-gray-50'}`}>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Users className={`w-3.5 h-3.5 flex-shrink-0 ${plan.highlighted ? 'text-brand-turquoise' : 'text-brand-turquoise'}`} />
|
|
||||||
<span className={`text-xs font-medium ${plan.highlighted ? 'text-white/80' : 'text-gray-700'}`}>{plan.users}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Ship className={`w-3.5 h-3.5 flex-shrink-0 ${plan.highlighted ? 'text-brand-turquoise' : 'text-brand-turquoise'}`} />
|
|
||||||
<span className={`text-xs font-medium ${plan.highlighted ? 'text-white/80' : 'text-gray-700'}`}>{plan.shipments}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<BarChart3 className={`w-3.5 h-3.5 flex-shrink-0 ${plan.highlighted ? 'text-brand-turquoise' : 'text-brand-turquoise'}`} />
|
|
||||||
<span className={`text-xs font-medium ${plan.highlighted ? 'text-white/80' : 'text-gray-700'}`}>
|
|
||||||
Commission {plan.commission}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Features */}
|
|
||||||
<ul className="space-y-2.5 mb-6 flex-1">
|
|
||||||
{plan.features.map((feature, featureIndex) => (
|
{plan.features.map((feature, featureIndex) => (
|
||||||
<li key={featureIndex} className="flex items-start gap-2.5">
|
<li key={featureIndex} className="flex items-center">
|
||||||
{feature.included ? (
|
{feature.included ? (
|
||||||
<Check className={`w-4 h-4 flex-shrink-0 mt-0.5 ${plan.highlighted ? 'text-brand-turquoise' : 'text-brand-green'}`} />
|
<Check className="w-5 h-5 text-brand-green mr-3 flex-shrink-0" />
|
||||||
) : (
|
) : (
|
||||||
<X className={`w-4 h-4 flex-shrink-0 mt-0.5 ${plan.highlighted ? 'text-white/20' : 'text-gray-300'}`} />
|
<X className="w-5 h-5 text-gray-300 mr-3 flex-shrink-0" />
|
||||||
)}
|
)}
|
||||||
<span className={`text-sm ${
|
<span className={feature.included ? 'text-gray-700' : 'text-gray-400'}>
|
||||||
feature.included
|
|
||||||
? plan.highlighted ? 'text-white/90' : 'text-gray-700'
|
|
||||||
: plan.highlighted ? 'text-white/30' : 'text-gray-400'
|
|
||||||
}`}>
|
|
||||||
{feature.text}
|
{feature.text}
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
{/* CTA */}
|
|
||||||
<Link
|
<Link
|
||||||
href={plan.ctaLink}
|
href={plan.name === 'Enterprise' ? '/contact' : '/register'}
|
||||||
className={`block w-full text-center py-3 px-6 rounded-xl font-semibold text-sm transition-all ${
|
target={plan.name === 'Enterprise' ? '_self' : '_blank'}
|
||||||
|
rel={plan.name === 'Enterprise' ? undefined : 'noopener noreferrer'}
|
||||||
|
className={`block w-full text-center py-3 px-6 rounded-lg font-semibold transition-all ${
|
||||||
plan.highlighted
|
plan.highlighted
|
||||||
? 'bg-brand-turquoise text-white hover:bg-brand-turquoise/90 shadow-lg shadow-brand-turquoise/30 hover:shadow-xl'
|
? 'bg-brand-turquoise text-white hover:bg-brand-turquoise/90 shadow-lg hover:shadow-xl'
|
||||||
: plan.key === 'bronze'
|
: 'bg-gray-100 text-brand-navy hover:bg-gray-200'
|
||||||
? 'bg-gray-100 text-brand-navy hover:bg-gray-200'
|
|
||||||
: 'bg-brand-navy text-white hover:bg-brand-navy/90 shadow-md hover:shadow-lg'
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{plan.cta}
|
{plan.cta}
|
||||||
@ -920,21 +734,17 @@ export default function LandingPage() {
|
|||||||
))}
|
))}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Bottom note */}
|
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={isPricingInView ? { opacity: 1, y: 0 } : {}}
|
animate={isPricingInView ? { opacity: 1, y: 0 } : {}}
|
||||||
transition={{ duration: 0.8, delay: 0.5 }}
|
transition={{ duration: 0.8, delay: 0.4 }}
|
||||||
className="mt-12 text-center space-y-2"
|
className="mt-12 text-center"
|
||||||
>
|
>
|
||||||
<p className="text-gray-600 text-sm">
|
<p className="text-gray-600">
|
||||||
Plans Silver et Gold : essai gratuit 14 jours inclus · Aucune carte bancaire requise
|
Tous les plans incluent un essai gratuit de 14 jours. Aucune carte bancaire requise.
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-gray-500 mt-2">
|
||||||
Des questions ?{' '}
|
Des questions ? <Link href="/contact" className="text-brand-turquoise hover:underline">Contactez notre équipe commerciale</Link>
|
||||||
<Link href="/contact" className="text-brand-turquoise font-medium hover:underline">
|
|
||||||
Contactez notre équipe commerciale
|
|
||||||
</Link>
|
|
||||||
</p>
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,307 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import Image from 'next/image';
|
|
||||||
import { Check, X, ArrowRight, Shield } from 'lucide-react';
|
|
||||||
|
|
||||||
type BillingInterval = 'monthly' | 'yearly';
|
|
||||||
|
|
||||||
const PLANS = [
|
|
||||||
{
|
|
||||||
name: 'Bronze',
|
|
||||||
key: 'BRONZE' as const,
|
|
||||||
monthlyPrice: 0,
|
|
||||||
yearlyPrice: 0,
|
|
||||||
description: 'Pour démarrer et tester la plateforme',
|
|
||||||
maxUsers: 1,
|
|
||||||
maxShipments: '12/an',
|
|
||||||
commission: '5%',
|
|
||||||
support: 'Aucun',
|
|
||||||
badge: null,
|
|
||||||
features: [
|
|
||||||
{ name: 'Recherche de tarifs', included: true },
|
|
||||||
{ name: 'Réservations', included: true },
|
|
||||||
{ name: 'Tableau de bord', included: false },
|
|
||||||
{ name: 'Wiki Maritime', included: false },
|
|
||||||
{ name: 'Gestion des utilisateurs', included: false },
|
|
||||||
{ name: 'Import CSV', included: false },
|
|
||||||
{ name: 'Accès API', included: false },
|
|
||||||
{ name: 'Interface personnalisée', included: false },
|
|
||||||
{ name: 'KAM dédié', included: false },
|
|
||||||
],
|
|
||||||
cta: 'Commencer gratuitement',
|
|
||||||
ctaStyle: 'bg-gray-900 text-white hover:bg-gray-800',
|
|
||||||
popular: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Silver',
|
|
||||||
key: 'SILVER' as const,
|
|
||||||
monthlyPrice: 249,
|
|
||||||
yearlyPrice: 2739,
|
|
||||||
description: 'Pour les équipes en croissance',
|
|
||||||
maxUsers: 5,
|
|
||||||
maxShipments: 'Illimitées',
|
|
||||||
commission: '3%',
|
|
||||||
support: 'Email',
|
|
||||||
badge: 'silver' as const,
|
|
||||||
features: [
|
|
||||||
{ name: 'Recherche de tarifs', included: true },
|
|
||||||
{ name: 'Réservations', included: true },
|
|
||||||
{ name: 'Tableau de bord', included: true },
|
|
||||||
{ name: 'Wiki Maritime', included: true },
|
|
||||||
{ name: 'Gestion des utilisateurs', included: true },
|
|
||||||
{ name: 'Import CSV', included: true },
|
|
||||||
{ name: 'Accès API', included: false },
|
|
||||||
{ name: 'Interface personnalisée', included: false },
|
|
||||||
{ name: 'KAM dédié', included: false },
|
|
||||||
],
|
|
||||||
cta: 'Choisir Silver',
|
|
||||||
ctaStyle: 'bg-brand-turquoise text-white hover:opacity-90',
|
|
||||||
popular: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Gold',
|
|
||||||
key: 'GOLD' as const,
|
|
||||||
monthlyPrice: 899,
|
|
||||||
yearlyPrice: 9889,
|
|
||||||
description: 'Pour les entreprises établies',
|
|
||||||
maxUsers: 20,
|
|
||||||
maxShipments: 'Illimitées',
|
|
||||||
commission: '2%',
|
|
||||||
support: 'Direct',
|
|
||||||
badge: 'gold' as const,
|
|
||||||
features: [
|
|
||||||
{ name: 'Recherche de tarifs', included: true },
|
|
||||||
{ name: 'Réservations', included: true },
|
|
||||||
{ name: 'Tableau de bord', included: true },
|
|
||||||
{ name: 'Wiki Maritime', included: true },
|
|
||||||
{ name: 'Gestion des utilisateurs', included: true },
|
|
||||||
{ name: 'Import CSV', included: true },
|
|
||||||
{ name: 'Accès API', included: true },
|
|
||||||
{ name: 'Interface personnalisée', included: false },
|
|
||||||
{ name: 'KAM dédié', included: false },
|
|
||||||
],
|
|
||||||
cta: 'Choisir Gold',
|
|
||||||
ctaStyle: 'bg-yellow-500 text-white hover:bg-yellow-600',
|
|
||||||
popular: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Platinium',
|
|
||||||
key: 'PLATINIUM' as const,
|
|
||||||
monthlyPrice: -1,
|
|
||||||
yearlyPrice: -1,
|
|
||||||
description: 'Solutions sur mesure',
|
|
||||||
maxUsers: 'Illimité',
|
|
||||||
maxShipments: 'Illimitées',
|
|
||||||
commission: '1%',
|
|
||||||
support: 'KAM dédié',
|
|
||||||
badge: 'platinium' as const,
|
|
||||||
features: [
|
|
||||||
{ name: 'Recherche de tarifs', included: true },
|
|
||||||
{ name: 'Réservations', included: true },
|
|
||||||
{ name: 'Tableau de bord', included: true },
|
|
||||||
{ name: 'Wiki Maritime', included: true },
|
|
||||||
{ name: 'Gestion des utilisateurs', included: true },
|
|
||||||
{ name: 'Import CSV', included: true },
|
|
||||||
{ name: 'Accès API', included: true },
|
|
||||||
{ name: 'Interface personnalisée', included: true },
|
|
||||||
{ name: 'KAM dédié', included: true },
|
|
||||||
],
|
|
||||||
cta: 'Nous contacter',
|
|
||||||
ctaStyle: 'bg-purple-600 text-white hover:bg-purple-700',
|
|
||||||
popular: false,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
function formatPrice(amount: number): string {
|
|
||||||
return new Intl.NumberFormat('fr-FR', {
|
|
||||||
style: 'currency',
|
|
||||||
currency: 'EUR',
|
|
||||||
minimumFractionDigits: 0,
|
|
||||||
maximumFractionDigits: 0,
|
|
||||||
}).format(amount);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PricingPage() {
|
|
||||||
const [billing, setBilling] = useState<BillingInterval>('monthly');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-white">
|
|
||||||
{/* Header */}
|
|
||||||
<header className="border-b">
|
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 flex items-center justify-between">
|
|
||||||
<Link href="/">
|
|
||||||
<Image
|
|
||||||
src="/assets/logos/logo-black.svg"
|
|
||||||
alt="Xpeditis"
|
|
||||||
width={40}
|
|
||||||
height={48}
|
|
||||||
priority
|
|
||||||
/>
|
|
||||||
</Link>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<Link href="/login" className="text-sm text-gray-600 hover:text-gray-900">
|
|
||||||
Connexion
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/register"
|
|
||||||
className="text-sm bg-brand-turquoise text-white px-4 py-2 rounded-lg hover:opacity-90"
|
|
||||||
>
|
|
||||||
Inscription
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* Hero */}
|
|
||||||
<section className="py-16 text-center">
|
|
||||||
<h1 className="text-4xl font-bold text-gray-900 mb-4">
|
|
||||||
Des tarifs simples et transparents
|
|
||||||
</h1>
|
|
||||||
<p className="text-lg text-gray-600 max-w-2xl mx-auto mb-8">
|
|
||||||
Choisissez la formule adaptée à votre activité de transport maritime.
|
|
||||||
Commencez gratuitement, évoluez selon vos besoins.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Billing toggle */}
|
|
||||||
<div className="flex items-center justify-center gap-4 mb-12">
|
|
||||||
<span className={`text-sm font-medium ${billing === 'monthly' ? 'text-gray-900' : 'text-gray-500'}`}>
|
|
||||||
Mensuel
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={() => setBilling(billing === 'monthly' ? 'yearly' : 'monthly')}
|
|
||||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
|
||||||
billing === 'yearly' ? 'bg-brand-turquoise' : 'bg-gray-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={`inline-block h-4 w-4 rounded-full bg-white transition-transform ${
|
|
||||||
billing === 'yearly' ? 'translate-x-6' : 'translate-x-1'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
<span className={`text-sm font-medium ${billing === 'yearly' ? 'text-gray-900' : 'text-gray-500'}`}>
|
|
||||||
Annuel
|
|
||||||
</span>
|
|
||||||
{billing === 'yearly' && (
|
|
||||||
<span className="text-xs bg-green-100 text-green-800 px-2 py-1 rounded-full font-medium">
|
|
||||||
-1 mois offert
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Plans grid */}
|
|
||||||
<section className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
|
||||||
{PLANS.map((plan) => (
|
|
||||||
<div
|
|
||||||
key={plan.key}
|
|
||||||
className={`relative rounded-2xl border-2 p-6 flex flex-col ${
|
|
||||||
plan.popular
|
|
||||||
? 'border-brand-turquoise shadow-lg shadow-brand-turquoise/10'
|
|
||||||
: 'border-gray-200'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{plan.popular && (
|
|
||||||
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
|
|
||||||
<span className="bg-brand-turquoise text-white text-xs font-semibold px-3 py-1 rounded-full">
|
|
||||||
Populaire
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Plan name & badge */}
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<h3 className="text-xl font-bold text-gray-900">{plan.name}</h3>
|
|
||||||
{plan.badge && (
|
|
||||||
<Shield className={`w-5 h-5 ${
|
|
||||||
plan.badge === 'silver' ? 'text-slate-500' :
|
|
||||||
plan.badge === 'gold' ? 'text-yellow-500' :
|
|
||||||
'text-purple-500'
|
|
||||||
}`} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-sm text-gray-500 mb-4">{plan.description}</p>
|
|
||||||
|
|
||||||
{/* Price */}
|
|
||||||
<div className="mb-6">
|
|
||||||
{plan.monthlyPrice === -1 ? (
|
|
||||||
<p className="text-3xl font-bold text-gray-900">Sur devis</p>
|
|
||||||
) : plan.monthlyPrice === 0 ? (
|
|
||||||
<p className="text-3xl font-bold text-gray-900">Gratuit</p>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<p className="text-3xl font-bold text-gray-900">
|
|
||||||
{billing === 'monthly'
|
|
||||||
? formatPrice(plan.monthlyPrice)
|
|
||||||
: formatPrice(Math.round(plan.yearlyPrice / 12))}
|
|
||||||
<span className="text-base font-normal text-gray-500">/mois</span>
|
|
||||||
</p>
|
|
||||||
{billing === 'yearly' && (
|
|
||||||
<p className="text-sm text-gray-500 mt-1">
|
|
||||||
{formatPrice(plan.yearlyPrice)}/an (11 mois)
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Quick stats */}
|
|
||||||
<div className="space-y-2 mb-6 text-sm">
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-gray-500">Utilisateurs</span>
|
|
||||||
<span className="font-medium">{plan.maxUsers}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-gray-500">Expéditions</span>
|
|
||||||
<span className="font-medium">{plan.maxShipments}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-gray-500">Commission</span>
|
|
||||||
<span className="font-medium">{plan.commission}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-gray-500">Support</span>
|
|
||||||
<span className="font-medium">{plan.support}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Features */}
|
|
||||||
<div className="flex-1 space-y-2 mb-6">
|
|
||||||
{plan.features.map((feature) => (
|
|
||||||
<div key={feature.name} className="flex items-center gap-2 text-sm">
|
|
||||||
{feature.included ? (
|
|
||||||
<Check className="w-4 h-4 text-green-500 flex-shrink-0" />
|
|
||||||
) : (
|
|
||||||
<X className="w-4 h-4 text-gray-300 flex-shrink-0" />
|
|
||||||
)}
|
|
||||||
<span className={feature.included ? 'text-gray-700' : 'text-gray-400'}>
|
|
||||||
{feature.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* CTA */}
|
|
||||||
<Link
|
|
||||||
href={plan.key === 'PLATINIUM' ? '/contact' : '/register'}
|
|
||||||
className={`block text-center py-3 px-4 rounded-lg text-sm font-semibold transition-all ${plan.ctaStyle}`}
|
|
||||||
>
|
|
||||||
{plan.cta}
|
|
||||||
<ArrowRight className="inline-block w-4 h-4 ml-1" />
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<footer className="border-t py-8 text-center text-sm text-gray-500">
|
|
||||||
<p>Tous les prix sont en euros HT. Facturation annuelle = 11 mois.</p>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -27,7 +27,6 @@ export default function RegisterPage() {
|
|||||||
// Organization fields
|
// Organization fields
|
||||||
const [organizationName, setOrganizationName] = useState('');
|
const [organizationName, setOrganizationName] = useState('');
|
||||||
const [organizationType, setOrganizationType] = useState<OrganizationType>('FREIGHT_FORWARDER');
|
const [organizationType, setOrganizationType] = useState<OrganizationType>('FREIGHT_FORWARDER');
|
||||||
const [siren, setSiren] = useState('');
|
|
||||||
const [street, setStreet] = useState('');
|
const [street, setStreet] = useState('');
|
||||||
const [city, setCity] = useState('');
|
const [city, setCity] = useState('');
|
||||||
const [state, setState] = useState('');
|
const [state, setState] = useState('');
|
||||||
@ -88,11 +87,6 @@ export default function RegisterPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!siren.trim() || !/^[0-9]{9}$/.test(siren)) {
|
|
||||||
setError('Le numero SIREN est requis (9 chiffres)');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!street.trim() || !city.trim() || !postalCode.trim() || !country.trim()) {
|
if (!street.trim() || !city.trim() || !postalCode.trim() || !country.trim()) {
|
||||||
setError('Tous les champs d\'adresse sont requis');
|
setError('Tous les champs d\'adresse sont requis');
|
||||||
return;
|
return;
|
||||||
@ -114,7 +108,6 @@ export default function RegisterPage() {
|
|||||||
organization: {
|
organization: {
|
||||||
name: organizationName,
|
name: organizationName,
|
||||||
type: organizationType,
|
type: organizationType,
|
||||||
siren,
|
|
||||||
street,
|
street,
|
||||||
city,
|
city,
|
||||||
state: state || undefined,
|
state: state || undefined,
|
||||||
@ -316,25 +309,6 @@ export default function RegisterPage() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* SIREN */}
|
|
||||||
<div className="mb-4">
|
|
||||||
<label htmlFor="siren" className="label">
|
|
||||||
Numero SIREN *
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="siren"
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
value={siren}
|
|
||||||
onChange={e => setSiren(e.target.value.replace(/\D/g, ''))}
|
|
||||||
className="input w-full"
|
|
||||||
placeholder="123456789"
|
|
||||||
maxLength={9}
|
|
||||||
disabled={isLoading}
|
|
||||||
/>
|
|
||||||
<p className="mt-1.5 text-body-xs text-neutral-500">9 chiffres, obligatoire pour toute organisation</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Street Address */}
|
{/* Street Address */}
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<label htmlFor="street" className="label">
|
<label htmlFor="street" className="label">
|
||||||
|
|||||||
@ -23,7 +23,6 @@ const prefixPublicPaths = [
|
|||||||
'/press',
|
'/press',
|
||||||
'/contact',
|
'/contact',
|
||||||
'/carrier',
|
'/carrier',
|
||||||
'/pricing',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export function middleware(request: NextRequest) {
|
export function middleware(request: NextRequest) {
|
||||||
|
|||||||
@ -7,8 +7,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { Download, FileSpreadsheet, FileText, ChevronDown, Lock } from 'lucide-react';
|
import { Download, FileSpreadsheet, FileText, ChevronDown } from 'lucide-react';
|
||||||
import { useSubscription } from '@/lib/context/subscription-context';
|
|
||||||
|
|
||||||
interface ExportButtonProps<T> {
|
interface ExportButtonProps<T> {
|
||||||
data: T[];
|
data: T[];
|
||||||
@ -27,8 +26,6 @@ export default function ExportButton<T extends Record<string, any>>({
|
|||||||
columns,
|
columns,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}: ExportButtonProps<T>) {
|
}: ExportButtonProps<T>) {
|
||||||
const { hasFeature } = useSubscription();
|
|
||||||
const canExport = hasFeature('csv_export');
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [isExporting, setIsExporting] = useState(false);
|
const [isExporting, setIsExporting] = useState(false);
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
@ -174,12 +171,9 @@ export default function ExportButton<T extends Record<string, any>>({
|
|||||||
return (
|
return (
|
||||||
<div className="relative" ref={dropdownRef}>
|
<div className="relative" ref={dropdownRef}>
|
||||||
<button
|
<button
|
||||||
onClick={() => canExport ? setIsOpen(!isOpen) : window.location.href = '/pricing'}
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
disabled={disabled || data.length === 0 || isExporting}
|
disabled={disabled || data.length === 0 || isExporting}
|
||||||
title={!canExport ? 'Passez au plan Silver pour exporter vos données' : undefined}
|
className="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
className={`inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md bg-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors ${
|
|
||||||
canExport ? 'text-gray-700 hover:bg-gray-50' : 'text-gray-400 hover:bg-gray-50'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{isExporting ? (
|
{isExporting ? (
|
||||||
<>
|
<>
|
||||||
@ -206,19 +200,15 @@ export default function ExportButton<T extends Record<string, any>>({
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{canExport ? (
|
<Download className="mr-2 h-4 w-4" />
|
||||||
<Download className="mr-2 h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
<Lock className="mr-2 h-4 w-4" />
|
|
||||||
)}
|
|
||||||
Exporter
|
Exporter
|
||||||
{canExport && <ChevronDown className="ml-2 h-4 w-4" />}
|
<ChevronDown className="ml-2 h-4 w-4" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Dropdown Menu */}
|
{/* Dropdown Menu */}
|
||||||
{isOpen && !isExporting && canExport && (
|
{isOpen && !isExporting && (
|
||||||
<div className="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-gray-200 z-50">
|
<div className="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-gray-200 z-50">
|
||||||
<div className="py-1">
|
<div className="py-1">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@ -80,11 +80,21 @@ export function LandingFooter() {
|
|||||||
Contact
|
Contact
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link href="/careers" className="hover:text-brand-turquoise transition-colors">
|
||||||
|
Carrières
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<Link href="/blog" className="hover:text-brand-turquoise transition-colors">
|
<Link href="/blog" className="hover:text-brand-turquoise transition-colors">
|
||||||
Blog
|
Blog
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link href="/press" className="hover:text-brand-turquoise transition-colors">
|
||||||
|
Presse
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -107,6 +117,11 @@ export function LandingFooter() {
|
|||||||
Politique de cookies
|
Politique de cookies
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link href="/security" className="hover:text-brand-turquoise transition-colors">
|
||||||
|
Sécurité
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<Link href="/compliance" className="hover:text-brand-turquoise transition-colors">
|
<Link href="/compliance" className="hover:text-brand-turquoise transition-colors">
|
||||||
Conformité RGPD
|
Conformité RGPD
|
||||||
|
|||||||
@ -6,6 +6,8 @@ import Image from 'next/image';
|
|||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import {
|
import {
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
Briefcase,
|
||||||
|
Newspaper,
|
||||||
Info,
|
Info,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
@ -24,12 +26,14 @@ export function LandingHeader({ transparentOnTop = false, activePage }: LandingH
|
|||||||
|
|
||||||
const companyMenuItems = [
|
const companyMenuItems = [
|
||||||
{ href: '/about', label: 'À propos', icon: Info, description: 'Notre histoire et mission' },
|
{ href: '/about', label: 'À propos', icon: Info, description: 'Notre histoire et mission' },
|
||||||
|
{ href: '/careers', label: 'Carrières', icon: Briefcase, description: 'Rejoignez-nous' },
|
||||||
{ href: '/blog', label: 'Blog', icon: BookOpen, description: 'Actualités et insights' },
|
{ href: '/blog', label: 'Blog', icon: BookOpen, description: 'Actualités et insights' },
|
||||||
|
{ href: '/press', label: 'Presse', icon: Newspaper, description: 'Espace presse' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// "Entreprise" dropdown is active only for its own sub-pages (not contact)
|
// "Entreprise" dropdown is active only for its own sub-pages (not contact)
|
||||||
const isCompanyMenuActive =
|
const isCompanyMenuActive =
|
||||||
activePage !== undefined && ['about', 'blog'].includes(activePage);
|
activePage !== undefined && ['about', 'careers', 'blog', 'press'].includes(activePage);
|
||||||
|
|
||||||
const getUserInitials = () => {
|
const getUserInitials = () => {
|
||||||
if (!user) return '';
|
if (!user) return '';
|
||||||
|
|||||||
@ -65,6 +65,7 @@ export default function LicensesTab() {
|
|||||||
<p className="text-2xl font-bold text-gray-900">
|
<p className="text-2xl font-bold text-gray-900">
|
||||||
{subscription?.usedLicenses || 0}
|
{subscription?.usedLicenses || 0}
|
||||||
</p>
|
</p>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">Hors ADMIN (illimité)</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white rounded-lg p-4 border border-gray-200">
|
<div className="bg-white rounded-lg p-4 border border-gray-200">
|
||||||
<p className="text-sm text-gray-500">Licences disponibles</p>
|
<p className="text-sm text-gray-500">Licences disponibles</p>
|
||||||
|
|||||||
@ -117,7 +117,7 @@ export default function SubscriptionTab() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const handleUpgrade = (plan: SubscriptionPlan) => {
|
const handleUpgrade = (plan: SubscriptionPlan) => {
|
||||||
if (plan === 'BRONZE') return;
|
if (plan === 'FREE') return;
|
||||||
setSelectedPlan(plan);
|
setSelectedPlan(plan);
|
||||||
checkoutMutation.mutate(plan);
|
checkoutMutation.mutate(plan);
|
||||||
};
|
};
|
||||||
@ -149,7 +149,7 @@ export default function SubscriptionTab() {
|
|||||||
|
|
||||||
const canUpgrade = (plan: SubscriptionPlan): boolean => {
|
const canUpgrade = (plan: SubscriptionPlan): boolean => {
|
||||||
if (!subscription) return false;
|
if (!subscription) return false;
|
||||||
const planOrder: SubscriptionPlan[] = ['BRONZE', 'SILVER', 'GOLD', 'PLATINIUM'];
|
const planOrder: SubscriptionPlan[] = ['FREE', 'STARTER', 'PRO', 'ENTERPRISE'];
|
||||||
return planOrder.indexOf(plan) > planOrder.indexOf(subscription.plan);
|
return planOrder.indexOf(plan) > planOrder.indexOf(subscription.plan);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -230,7 +230,7 @@ export default function SubscriptionTab() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{subscription.plan !== 'BRONZE' && (
|
{subscription.plan !== 'FREE' && (
|
||||||
<button
|
<button
|
||||||
onClick={handleManageBilling}
|
onClick={handleManageBilling}
|
||||||
disabled={portalMutation.isPending}
|
disabled={portalMutation.isPending}
|
||||||
@ -314,7 +314,7 @@ export default function SubscriptionTab() {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Annuel
|
Annuel
|
||||||
<span className="ml-1 text-xs text-green-600">-1 mois</span>
|
<span className="ml-1 text-xs text-green-600">-20%</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -333,7 +333,7 @@ export default function SubscriptionTab() {
|
|||||||
<h4 className="text-lg font-semibold text-gray-900">{plan.name}</h4>
|
<h4 className="text-lg font-semibold text-gray-900">{plan.name}</h4>
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<span className="text-2xl font-bold text-gray-900">
|
<span className="text-2xl font-bold text-gray-900">
|
||||||
{plan.plan === 'PLATINIUM'
|
{plan.plan === 'ENTERPRISE'
|
||||||
? 'Sur devis'
|
? 'Sur devis'
|
||||||
: formatPrice(
|
: formatPrice(
|
||||||
billingInterval === 'yearly'
|
billingInterval === 'yearly'
|
||||||
@ -341,7 +341,7 @@ export default function SubscriptionTab() {
|
|||||||
: plan.monthlyPriceEur,
|
: plan.monthlyPriceEur,
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
{plan.plan !== 'PLATINIUM' && plan.plan !== 'BRONZE' && (
|
{plan.plan !== 'ENTERPRISE' && plan.plan !== 'FREE' && (
|
||||||
<span className="text-gray-500 text-sm">
|
<span className="text-gray-500 text-sm">
|
||||||
/{billingInterval === 'yearly' ? 'an' : 'mois'}
|
/{billingInterval === 'yearly' ? 'an' : 'mois'}
|
||||||
</span>
|
</span>
|
||||||
@ -381,7 +381,7 @@ export default function SubscriptionTab() {
|
|||||||
>
|
>
|
||||||
Plan actuel
|
Plan actuel
|
||||||
</button>
|
</button>
|
||||||
) : plan.plan === 'PLATINIUM' ? (
|
) : plan.plan === 'ENTERPRISE' ? (
|
||||||
<a
|
<a
|
||||||
href="mailto:sales@xpeditis.com?subject=Demande Enterprise"
|
href="mailto:sales@xpeditis.com?subject=Demande Enterprise"
|
||||||
className="block w-full px-4 py-2 text-sm font-medium text-center text-blue-600 bg-blue-50 rounded-lg hover:bg-blue-100 transition"
|
className="block w-full px-4 py-2 text-sm font-medium text-center text-blue-600 bg-blue-50 rounded-lg hover:bg-blue-100 transition"
|
||||||
|
|||||||
@ -9,7 +9,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { AuthProvider } from '@/lib/context/auth-context';
|
import { AuthProvider } from '@/lib/context/auth-context';
|
||||||
import { SubscriptionProvider } from '@/lib/context/subscription-context';
|
|
||||||
import { CookieProvider } from '@/lib/context/cookie-context';
|
import { CookieProvider } from '@/lib/context/cookie-context';
|
||||||
import CookieConsent from '@/components/CookieConsent';
|
import CookieConsent from '@/components/CookieConsent';
|
||||||
|
|
||||||
@ -31,12 +30,10 @@ export function Providers({ children }: { children: React.ReactNode }) {
|
|||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<SubscriptionProvider>
|
<CookieProvider>
|
||||||
<CookieProvider>
|
{children}
|
||||||
{children}
|
<CookieConsent />
|
||||||
<CookieConsent />
|
</CookieProvider>
|
||||||
</CookieProvider>
|
|
||||||
</SubscriptionProvider>
|
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,68 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { Lock } from 'lucide-react';
|
|
||||||
import { useSubscription } from '@/lib/context/subscription-context';
|
|
||||||
import type { PlanFeature } from '@/lib/api/subscriptions';
|
|
||||||
|
|
||||||
interface FeatureGateProps {
|
|
||||||
feature: PlanFeature;
|
|
||||||
children: React.ReactNode;
|
|
||||||
fallback?: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const FEATURE_MIN_PLAN: Record<PlanFeature, string> = {
|
|
||||||
dashboard: 'Silver',
|
|
||||||
wiki: 'Silver',
|
|
||||||
user_management: 'Silver',
|
|
||||||
csv_export: 'Silver',
|
|
||||||
api_access: 'Gold',
|
|
||||||
custom_interface: 'Platinium',
|
|
||||||
dedicated_kam: 'Platinium',
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function FeatureGate({ feature, children, fallback }: FeatureGateProps) {
|
|
||||||
const { hasFeature, loading } = useSubscription();
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return <>{children}</>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasFeature(feature)) {
|
|
||||||
return <>{children}</>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fallback) {
|
|
||||||
return <>{fallback}</>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const minPlan = FEATURE_MIN_PLAN[feature] || 'Silver';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative">
|
|
||||||
<div className="opacity-30 pointer-events-none select-none blur-sm">
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-white/60 backdrop-blur-[2px] rounded-lg">
|
|
||||||
<div className="text-center p-8 max-w-md">
|
|
||||||
<div className="mx-auto w-12 h-12 bg-gray-100 rounded-full flex items-center justify-center mb-4">
|
|
||||||
<Lock className="w-6 h-6 text-gray-400" />
|
|
||||||
</div>
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
|
||||||
Fonctionnalité {minPlan}+
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-gray-600 mb-4">
|
|
||||||
Cette fonctionnalité nécessite le plan {minPlan} ou supérieur.
|
|
||||||
</p>
|
|
||||||
<Link
|
|
||||||
href="/pricing"
|
|
||||||
className="inline-flex items-center px-4 py-2 bg-brand-turquoise text-white text-sm font-medium rounded-lg hover:opacity-90 transition-opacity"
|
|
||||||
>
|
|
||||||
Voir les plans
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,49 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import { Shield } from 'lucide-react';
|
|
||||||
|
|
||||||
interface StatusBadgeProps {
|
|
||||||
badge: 'none' | 'silver' | 'gold' | 'platinium';
|
|
||||||
size?: 'sm' | 'md';
|
|
||||||
}
|
|
||||||
|
|
||||||
const BADGE_CONFIG = {
|
|
||||||
none: null,
|
|
||||||
silver: {
|
|
||||||
label: 'Silver',
|
|
||||||
bg: 'bg-slate-100',
|
|
||||||
text: 'text-slate-700',
|
|
||||||
icon: 'text-slate-500',
|
|
||||||
},
|
|
||||||
gold: {
|
|
||||||
label: 'Gold',
|
|
||||||
bg: 'bg-yellow-100',
|
|
||||||
text: 'text-yellow-800',
|
|
||||||
icon: 'text-yellow-600',
|
|
||||||
},
|
|
||||||
platinium: {
|
|
||||||
label: 'Platinium',
|
|
||||||
bg: 'bg-purple-100',
|
|
||||||
text: 'text-purple-800',
|
|
||||||
icon: 'text-purple-600',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function StatusBadge({ badge, size = 'sm' }: StatusBadgeProps) {
|
|
||||||
const config = BADGE_CONFIG[badge];
|
|
||||||
if (!config) return null;
|
|
||||||
|
|
||||||
const sizeClasses = size === 'sm'
|
|
||||||
? 'text-xs px-2 py-0.5 gap-1'
|
|
||||||
: 'text-sm px-3 py-1 gap-1.5';
|
|
||||||
|
|
||||||
const iconSize = size === 'sm' ? 'w-3 h-3' : 'w-4 h-4';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span className={`inline-flex items-center font-medium rounded-full ${config.bg} ${config.text} ${sizeClasses}`}>
|
|
||||||
<Shield className={`${iconSize} ${config.icon}`} />
|
|
||||||
{config.label}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -80,39 +80,6 @@ export async function getAdminOrganization(id: string): Promise<OrganizationResp
|
|||||||
return get<OrganizationResponse>(`/api/v1/admin/organizations/${id}`);
|
return get<OrganizationResponse>(`/api/v1/admin/organizations/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Verify SIRET for an organization via Pappers API (admin only)
|
|
||||||
* POST /api/v1/admin/organizations/:id/verify-siret
|
|
||||||
* Requires: ADMIN role
|
|
||||||
*/
|
|
||||||
export async function verifySiret(
|
|
||||||
organizationId: string
|
|
||||||
): Promise<{ verified: boolean; companyName?: string; address?: string; message: string }> {
|
|
||||||
return post(`/api/v1/admin/organizations/${organizationId}/verify-siret`, {});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Manually approve SIRET/SIREN for an organization (admin only)
|
|
||||||
* POST /api/v1/admin/organizations/:id/approve-siret
|
|
||||||
* Requires: ADMIN role
|
|
||||||
*/
|
|
||||||
export async function approveSiret(
|
|
||||||
organizationId: string
|
|
||||||
): Promise<{ approved: boolean; message: string; organizationName: string }> {
|
|
||||||
return post(`/api/v1/admin/organizations/${organizationId}/approve-siret`, {});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reject SIRET/SIREN for an organization (admin only)
|
|
||||||
* POST /api/v1/admin/organizations/:id/reject-siret
|
|
||||||
* Requires: ADMIN role
|
|
||||||
*/
|
|
||||||
export async function rejectSiret(
|
|
||||||
organizationId: string
|
|
||||||
): Promise<{ rejected: boolean; message: string; organizationName: string }> {
|
|
||||||
return post(`/api/v1/admin/organizations/${organizationId}/reject-siret`, {});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== BOOKINGS ====================
|
// ==================== BOOKINGS ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -134,16 +101,6 @@ export async function getAdminBooking(id: string): Promise<BookingResponse> {
|
|||||||
return get<BookingResponse>(`/api/v1/admin/bookings/${id}`);
|
return get<BookingResponse>(`/api/v1/admin/bookings/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate bank transfer for a booking (admin only)
|
|
||||||
* POST /api/v1/admin/bookings/:id/validate-transfer
|
|
||||||
* Confirms receipt of wire transfer and activates the booking
|
|
||||||
* Requires: ADMIN role
|
|
||||||
*/
|
|
||||||
export async function validateBankTransfer(bookingId: string): Promise<BookingResponse> {
|
|
||||||
return post<BookingResponse>(`/api/v1/admin/bookings/${bookingId}/validate-transfer`, {});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== DOCUMENTS ====================
|
// ==================== DOCUMENTS ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -51,7 +51,7 @@ export interface CsvBookingResponse {
|
|||||||
primaryCurrency: string;
|
primaryCurrency: string;
|
||||||
transitDays: number;
|
transitDays: number;
|
||||||
containerType: string;
|
containerType: string;
|
||||||
status: 'PENDING_PAYMENT' | 'PENDING' | 'ACCEPTED' | 'REJECTED' | 'CANCELLED';
|
status: 'PENDING' | 'ACCEPTED' | 'REJECTED';
|
||||||
documents: Array<{
|
documents: Array<{
|
||||||
type: string;
|
type: string;
|
||||||
fileName: string;
|
fileName: string;
|
||||||
@ -64,14 +64,6 @@ export interface CsvBookingResponse {
|
|||||||
rejectedAt?: string;
|
rejectedAt?: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
commissionRate?: number;
|
|
||||||
commissionAmountEur?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CommissionPaymentResponse {
|
|
||||||
sessionUrl: string;
|
|
||||||
sessionId: string;
|
|
||||||
commissionAmountEur: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CsvBookingListResponse {
|
export interface CsvBookingListResponse {
|
||||||
@ -295,34 +287,3 @@ export async function rejectCsvBooking(
|
|||||||
false // includeAuth = false
|
false // includeAuth = false
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Create Stripe Checkout session for commission payment
|
|
||||||
* POST /api/v1/csv-bookings/:id/pay
|
|
||||||
*/
|
|
||||||
export async function payBookingCommission(
|
|
||||||
bookingId: string
|
|
||||||
): Promise<CommissionPaymentResponse> {
|
|
||||||
return post<CommissionPaymentResponse>(`/api/v1/csv-bookings/${bookingId}/pay`, {});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Confirm commission payment after Stripe redirect
|
|
||||||
* POST /api/v1/csv-bookings/:id/confirm-payment
|
|
||||||
*/
|
|
||||||
export async function confirmBookingPayment(
|
|
||||||
bookingId: string,
|
|
||||||
sessionId: string
|
|
||||||
): Promise<CsvBookingResponse> {
|
|
||||||
return post<CsvBookingResponse>(`/api/v1/csv-bookings/${bookingId}/confirm-payment`, {
|
|
||||||
sessionId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Declare bank transfer — user confirms they have sent the wire transfer
|
|
||||||
* POST /api/v1/csv-bookings/:id/declare-transfer
|
|
||||||
*/
|
|
||||||
export async function declareBankTransfer(bookingId: string): Promise<CsvBookingResponse> {
|
|
||||||
return post<CsvBookingResponse>(`/api/v1/csv-bookings/${bookingId}/declare-transfer`, {});
|
|
||||||
}
|
|
||||||
|
|||||||
@ -9,16 +9,7 @@ import { get, post } from './client';
|
|||||||
/**
|
/**
|
||||||
* Subscription plan types
|
* Subscription plan types
|
||||||
*/
|
*/
|
||||||
export type SubscriptionPlan = 'BRONZE' | 'SILVER' | 'GOLD' | 'PLATINIUM';
|
export type SubscriptionPlan = 'FREE' | 'STARTER' | 'PRO' | 'ENTERPRISE';
|
||||||
|
|
||||||
export type PlanFeature =
|
|
||||||
| 'dashboard'
|
|
||||||
| 'wiki'
|
|
||||||
| 'user_management'
|
|
||||||
| 'csv_export'
|
|
||||||
| 'api_access'
|
|
||||||
| 'custom_interface'
|
|
||||||
| 'dedicated_kam';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Subscription status types
|
* Subscription status types
|
||||||
@ -47,11 +38,6 @@ export interface PlanDetails {
|
|||||||
maxLicenses: number;
|
maxLicenses: number;
|
||||||
monthlyPriceEur: number;
|
monthlyPriceEur: number;
|
||||||
yearlyPriceEur: number;
|
yearlyPriceEur: number;
|
||||||
maxShipmentsPerYear: number;
|
|
||||||
commissionRatePercent: number;
|
|
||||||
supportLevel: 'none' | 'email' | 'direct' | 'dedicated_kam';
|
|
||||||
statusBadge: 'none' | 'silver' | 'gold' | 'platinium';
|
|
||||||
planFeatures: PlanFeature[];
|
|
||||||
features: string[];
|
features: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -204,14 +190,14 @@ export function formatPrice(amount: number, currency = 'EUR'): string {
|
|||||||
*/
|
*/
|
||||||
export function getPlanBadgeColor(plan: SubscriptionPlan): string {
|
export function getPlanBadgeColor(plan: SubscriptionPlan): string {
|
||||||
switch (plan) {
|
switch (plan) {
|
||||||
case 'BRONZE':
|
case 'FREE':
|
||||||
return 'bg-orange-100 text-orange-800';
|
return 'bg-gray-100 text-gray-800';
|
||||||
case 'SILVER':
|
case 'STARTER':
|
||||||
return 'bg-slate-100 text-slate-800';
|
return 'bg-blue-100 text-blue-800';
|
||||||
case 'GOLD':
|
case 'PRO':
|
||||||
return 'bg-yellow-100 text-yellow-800';
|
|
||||||
case 'PLATINIUM':
|
|
||||||
return 'bg-purple-100 text-purple-800';
|
return 'bg-purple-100 text-purple-800';
|
||||||
|
case 'ENTERPRISE':
|
||||||
|
return 'bg-amber-100 text-amber-800';
|
||||||
default:
|
default:
|
||||||
return 'bg-gray-100 text-gray-800';
|
return 'bg-gray-100 text-gray-800';
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,77 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
|
||||||
import { useAuth } from './auth-context';
|
|
||||||
import {
|
|
||||||
getSubscriptionOverview,
|
|
||||||
type SubscriptionOverviewResponse,
|
|
||||||
type SubscriptionPlan,
|
|
||||||
type PlanFeature,
|
|
||||||
} from '../api/subscriptions';
|
|
||||||
|
|
||||||
interface SubscriptionContextType {
|
|
||||||
subscription: SubscriptionOverviewResponse | null;
|
|
||||||
loading: boolean;
|
|
||||||
plan: SubscriptionPlan | null;
|
|
||||||
planFeatures: PlanFeature[];
|
|
||||||
hasFeature: (feature: PlanFeature) => boolean;
|
|
||||||
refresh: () => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SubscriptionContext = createContext<SubscriptionContextType | undefined>(undefined);
|
|
||||||
|
|
||||||
export function SubscriptionProvider({ children }: { children: React.ReactNode }) {
|
|
||||||
const { user, isAuthenticated } = useAuth();
|
|
||||||
const [subscription, setSubscription] = useState<SubscriptionOverviewResponse | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
const fetchSubscription = async () => {
|
|
||||||
if (!isAuthenticated) {
|
|
||||||
setSubscription(null);
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const data = await getSubscriptionOverview();
|
|
||||||
setSubscription(data);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch subscription:', error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchSubscription();
|
|
||||||
}, [isAuthenticated, user?.organizationId]);
|
|
||||||
|
|
||||||
const plan = subscription?.plan ?? null;
|
|
||||||
const planFeatures = subscription?.planDetails?.planFeatures ?? [];
|
|
||||||
|
|
||||||
const hasFeature = (feature: PlanFeature): boolean => {
|
|
||||||
return planFeatures.includes(feature);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SubscriptionContext.Provider
|
|
||||||
value={{
|
|
||||||
subscription,
|
|
||||||
loading,
|
|
||||||
plan,
|
|
||||||
planFeatures,
|
|
||||||
hasFeature,
|
|
||||||
refresh: fetchSubscription,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</SubscriptionContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useSubscription() {
|
|
||||||
const context = useContext(SubscriptionContext);
|
|
||||||
if (context === undefined) {
|
|
||||||
throw new Error('useSubscription must be used within a SubscriptionProvider');
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
}
|
|
||||||
@ -11,7 +11,6 @@
|
|||||||
export interface RegisterOrganizationData {
|
export interface RegisterOrganizationData {
|
||||||
name: string;
|
name: string;
|
||||||
type: OrganizationType;
|
type: OrganizationType;
|
||||||
siren: string;
|
|
||||||
street: string;
|
street: string;
|
||||||
city: string;
|
city: string;
|
||||||
state?: string;
|
state?: string;
|
||||||
@ -121,7 +120,6 @@ export interface CreateOrganizationRequest {
|
|||||||
export interface UpdateOrganizationRequest {
|
export interface UpdateOrganizationRequest {
|
||||||
name?: string;
|
name?: string;
|
||||||
siren?: string;
|
siren?: string;
|
||||||
siret?: string;
|
|
||||||
eori?: string;
|
eori?: string;
|
||||||
contact_phone?: string;
|
contact_phone?: string;
|
||||||
contact_email?: string;
|
contact_email?: string;
|
||||||
@ -151,9 +149,6 @@ export interface OrganizationResponse {
|
|||||||
scac?: string | null;
|
scac?: string | null;
|
||||||
siren?: string | null;
|
siren?: string | null;
|
||||||
eori?: string | null;
|
eori?: string | null;
|
||||||
siret?: string | null;
|
|
||||||
siretVerified?: boolean;
|
|
||||||
statusBadge?: 'none' | 'silver' | 'gold' | 'platinium';
|
|
||||||
contact_phone?: string | null;
|
contact_phone?: string | null;
|
||||||
contact_email?: string | null;
|
contact_email?: string | null;
|
||||||
address: OrganizationAddress;
|
address: OrganizationAddress;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user