Compare commits
No commits in common. "301409624bc6578d8561bd09d8c48bf4d6e6f36f" and "0d814e9a943f63c46564dc87db9c266cc0b34365" have entirely different histories.
301409624b
...
0d814e9a94
@ -84,18 +84,3 @@ RATE_LIMIT_MAX=100
|
|||||||
|
|
||||||
# Monitoring
|
# Monitoring
|
||||||
SENTRY_DSN=your-sentry-dsn
|
SENTRY_DSN=your-sentry-dsn
|
||||||
|
|
||||||
# Frontend URL (for redirects)
|
|
||||||
FRONTEND_URL=http://localhost:3000
|
|
||||||
|
|
||||||
# Stripe (Subscriptions & Payments)
|
|
||||||
STRIPE_SECRET_KEY=sk_test_your_stripe_secret_key
|
|
||||||
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret
|
|
||||||
|
|
||||||
# Stripe Price IDs (create these in Stripe Dashboard)
|
|
||||||
STRIPE_STARTER_MONTHLY_PRICE_ID=price_starter_monthly
|
|
||||||
STRIPE_STARTER_YEARLY_PRICE_ID=price_starter_yearly
|
|
||||||
STRIPE_PRO_MONTHLY_PRICE_ID=price_pro_monthly
|
|
||||||
STRIPE_PRO_YEARLY_PRICE_ID=price_pro_yearly
|
|
||||||
STRIPE_ENTERPRISE_MONTHLY_PRICE_ID=price_enterprise_monthly
|
|
||||||
STRIPE_ENTERPRISE_YEARLY_PRICE_ID=price_enterprise_yearly
|
|
||||||
|
|||||||
17
apps/backend/package-lock.json
generated
17
apps/backend/package-lock.json
generated
@ -59,9 +59,7 @@
|
|||||||
"reflect-metadata": "^0.1.14",
|
"reflect-metadata": "^0.1.14",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"socket.io": "^4.8.1",
|
"socket.io": "^4.8.1",
|
||||||
"stripe": "^14.14.0",
|
"typeorm": "^0.3.17"
|
||||||
"typeorm": "^0.3.17",
|
|
||||||
"uuid": "^9.0.1"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@faker-js/faker": "^10.0.0",
|
"@faker-js/faker": "^10.0.0",
|
||||||
@ -14572,19 +14570,6 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/stripe": {
|
|
||||||
"version": "14.25.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/stripe/-/stripe-14.25.0.tgz",
|
|
||||||
"integrity": "sha512-wQS3GNMofCXwH8TSje8E1SE8zr6ODiGtHQgPtO95p9Mb4FhKC9jvXR2NUTpZ9ZINlckJcFidCmaTFV4P6vsb9g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/node": ">=8.1.0",
|
|
||||||
"qs": "^6.11.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12.*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/strnum": {
|
"node_modules/strnum": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.1.tgz",
|
||||||
|
|||||||
@ -75,9 +75,7 @@
|
|||||||
"reflect-metadata": "^0.1.14",
|
"reflect-metadata": "^0.1.14",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"socket.io": "^4.8.1",
|
"socket.io": "^4.8.1",
|
||||||
"stripe": "^14.14.0",
|
"typeorm": "^0.3.17"
|
||||||
"typeorm": "^0.3.17",
|
|
||||||
"uuid": "^9.0.1"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@faker-js/faker": "^10.0.0",
|
"@faker-js/faker": "^10.0.0",
|
||||||
|
|||||||
@ -1,55 +0,0 @@
|
|||||||
/**
|
|
||||||
* Script to list all Stripe prices
|
|
||||||
* Run with: node scripts/list-stripe-prices.js
|
|
||||||
*/
|
|
||||||
|
|
||||||
const Stripe = require('stripe');
|
|
||||||
|
|
||||||
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || 'sk_test_51R8p8R4atifoBlu1U9sMJh3rkQbO1G1xeguwFMQYMIMeaLNrTX7YFO5Ovu3P1VfbwcOoEmiy6I0UWi4DThNNzHG100YF75TnJr');
|
|
||||||
|
|
||||||
async function listPrices() {
|
|
||||||
console.log('Fetching Stripe prices...\n');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const prices = await stripe.prices.list({ limit: 50, expand: ['data.product'] });
|
|
||||||
|
|
||||||
if (prices.data.length === 0) {
|
|
||||||
console.log('No prices found. You need to create prices in Stripe Dashboard.');
|
|
||||||
console.log('\nSteps:');
|
|
||||||
console.log('1. Go to https://dashboard.stripe.com/products');
|
|
||||||
console.log('2. Click on each product (Starter, Pro, Enterprise)');
|
|
||||||
console.log('3. Add a recurring price (monthly and yearly)');
|
|
||||||
console.log('4. Copy the Price IDs (format: price_xxxxx)');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Available Prices:\n');
|
|
||||||
console.log('='.repeat(100));
|
|
||||||
|
|
||||||
for (const price of prices.data) {
|
|
||||||
const product = typeof price.product === 'object' ? price.product : { name: price.product };
|
|
||||||
const interval = price.recurring ? `${price.recurring.interval}ly` : 'one-time';
|
|
||||||
const amount = (price.unit_amount / 100).toFixed(2);
|
|
||||||
|
|
||||||
console.log(`Price ID: ${price.id}`);
|
|
||||||
console.log(`Product: ${product.name || product.id}`);
|
|
||||||
console.log(`Amount: ${amount} ${price.currency.toUpperCase()}`);
|
|
||||||
console.log(`Interval: ${interval}`);
|
|
||||||
console.log(`Active: ${price.active}`);
|
|
||||||
console.log('-'.repeat(100));
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('\n\nCopy the relevant Price IDs to your .env file:');
|
|
||||||
console.log('STRIPE_STARTER_MONTHLY_PRICE_ID=price_xxxxx');
|
|
||||||
console.log('STRIPE_STARTER_YEARLY_PRICE_ID=price_xxxxx');
|
|
||||||
console.log('STRIPE_PRO_MONTHLY_PRICE_ID=price_xxxxx');
|
|
||||||
console.log('STRIPE_PRO_YEARLY_PRICE_ID=price_xxxxx');
|
|
||||||
console.log('STRIPE_ENTERPRISE_MONTHLY_PRICE_ID=price_xxxxx');
|
|
||||||
console.log('STRIPE_ENTERPRISE_YEARLY_PRICE_ID=price_xxxxx');
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching prices:', error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
listPrices();
|
|
||||||
@ -19,7 +19,6 @@ import { WebhooksModule } from './application/webhooks/webhooks.module';
|
|||||||
import { GDPRModule } from './application/gdpr/gdpr.module';
|
import { GDPRModule } from './application/gdpr/gdpr.module';
|
||||||
import { CsvBookingsModule } from './application/csv-bookings.module';
|
import { CsvBookingsModule } from './application/csv-bookings.module';
|
||||||
import { AdminModule } from './application/admin/admin.module';
|
import { AdminModule } from './application/admin/admin.module';
|
||||||
import { SubscriptionsModule } from './application/subscriptions/subscriptions.module';
|
|
||||||
import { CacheModule } from './infrastructure/cache/cache.module';
|
import { CacheModule } from './infrastructure/cache/cache.module';
|
||||||
import { CarrierModule } from './infrastructure/carriers/carrier.module';
|
import { CarrierModule } from './infrastructure/carriers/carrier.module';
|
||||||
import { SecurityModule } from './infrastructure/security/security.module';
|
import { SecurityModule } from './infrastructure/security/security.module';
|
||||||
@ -57,15 +56,6 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard';
|
|||||||
SMTP_PASS: Joi.string().required(),
|
SMTP_PASS: Joi.string().required(),
|
||||||
SMTP_FROM: Joi.string().email().default('noreply@xpeditis.com'),
|
SMTP_FROM: Joi.string().email().default('noreply@xpeditis.com'),
|
||||||
SMTP_SECURE: Joi.boolean().default(false),
|
SMTP_SECURE: Joi.boolean().default(false),
|
||||||
// Stripe Configuration (optional for development)
|
|
||||||
STRIPE_SECRET_KEY: Joi.string().optional(),
|
|
||||||
STRIPE_WEBHOOK_SECRET: Joi.string().optional(),
|
|
||||||
STRIPE_STARTER_MONTHLY_PRICE_ID: Joi.string().optional(),
|
|
||||||
STRIPE_STARTER_YEARLY_PRICE_ID: Joi.string().optional(),
|
|
||||||
STRIPE_PRO_MONTHLY_PRICE_ID: Joi.string().optional(),
|
|
||||||
STRIPE_PRO_YEARLY_PRICE_ID: Joi.string().optional(),
|
|
||||||
STRIPE_ENTERPRISE_MONTHLY_PRICE_ID: Joi.string().optional(),
|
|
||||||
STRIPE_ENTERPRISE_YEARLY_PRICE_ID: Joi.string().optional(),
|
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@ -127,7 +117,6 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard';
|
|||||||
WebhooksModule,
|
WebhooksModule,
|
||||||
GDPRModule,
|
GDPRModule,
|
||||||
AdminModule,
|
AdminModule,
|
||||||
SubscriptionsModule,
|
|
||||||
],
|
],
|
||||||
controllers: [],
|
controllers: [],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@ -20,7 +20,6 @@ import { InvitationTokenOrmEntity } from '../../infrastructure/persistence/typeo
|
|||||||
import { InvitationService } from '../services/invitation.service';
|
import { InvitationService } from '../services/invitation.service';
|
||||||
import { InvitationsController } from '../controllers/invitations.controller';
|
import { InvitationsController } from '../controllers/invitations.controller';
|
||||||
import { EmailModule } from '../../infrastructure/email/email.module';
|
import { EmailModule } from '../../infrastructure/email/email.module';
|
||||||
import { SubscriptionsModule } from '../subscriptions/subscriptions.module';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@ -44,9 +43,6 @@ import { SubscriptionsModule } from '../subscriptions/subscriptions.module';
|
|||||||
|
|
||||||
// Email module for sending invitations
|
// Email module for sending invitations
|
||||||
EmailModule,
|
EmailModule,
|
||||||
|
|
||||||
// Subscriptions module for license checks
|
|
||||||
SubscriptionsModule,
|
|
||||||
],
|
],
|
||||||
controllers: [AuthController, InvitationsController],
|
controllers: [AuthController, InvitationsController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@ -18,7 +18,6 @@ import {
|
|||||||
import { Organization } from '@domain/entities/organization.entity';
|
import { Organization } from '@domain/entities/organization.entity';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { RegisterOrganizationDto } from '../dto/auth-login.dto';
|
import { RegisterOrganizationDto } from '../dto/auth-login.dto';
|
||||||
import { SubscriptionService } from '../services/subscription.service';
|
|
||||||
|
|
||||||
export interface JwtPayload {
|
export interface JwtPayload {
|
||||||
sub: string; // user ID
|
sub: string; // user ID
|
||||||
@ -38,8 +37,7 @@ export class AuthService {
|
|||||||
@Inject(ORGANIZATION_REPOSITORY)
|
@Inject(ORGANIZATION_REPOSITORY)
|
||||||
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,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -102,16 +100,6 @@ export class AuthService {
|
|||||||
|
|
||||||
const savedUser = await this.userRepository.save(user);
|
const savedUser = await this.userRepository.save(user);
|
||||||
|
|
||||||
// Allocate a license for the new user
|
|
||||||
try {
|
|
||||||
await this.subscriptionService.allocateLicense(savedUser.id, finalOrganizationId);
|
|
||||||
this.logger.log(`License allocated for user: ${email}`);
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to allocate license for user ${email}:`, error);
|
|
||||||
// Note: We don't throw here because the user is already created.
|
|
||||||
// The license check should happen before invitation.
|
|
||||||
}
|
|
||||||
|
|
||||||
const tokens = await this.generateTokens(savedUser);
|
const tokens = await this.generateTokens(savedUser);
|
||||||
|
|
||||||
this.logger.log(`User registered successfully: ${email}`);
|
this.logger.log(`User registered successfully: ${email}`);
|
||||||
|
|||||||
@ -372,20 +372,14 @@ export class BookingsController {
|
|||||||
const endIndex = startIndex + pageSize;
|
const endIndex = startIndex + pageSize;
|
||||||
const paginatedBookings = filteredBookings.slice(startIndex, endIndex);
|
const paginatedBookings = filteredBookings.slice(startIndex, endIndex);
|
||||||
|
|
||||||
// Fetch rate quotes for all bookings (filter out those with missing rate quotes)
|
// Fetch rate quotes for all bookings
|
||||||
const bookingsWithQuotesRaw = await Promise.all(
|
const bookingsWithQuotes = await Promise.all(
|
||||||
paginatedBookings.map(async (booking: any) => {
|
paginatedBookings.map(async (booking: any) => {
|
||||||
const rateQuote = await this.rateQuoteRepository.findById(booking.rateQuoteId);
|
const rateQuote = await this.rateQuoteRepository.findById(booking.rateQuoteId);
|
||||||
return { booking, rateQuote };
|
return { booking, rateQuote: rateQuote! };
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// Filter out bookings with missing rate quotes to avoid null pointer errors
|
|
||||||
const bookingsWithQuotes = bookingsWithQuotesRaw.filter(
|
|
||||||
(item): item is { booking: any; rateQuote: NonNullable<typeof item.rateQuote> } =>
|
|
||||||
item.rateQuote !== null && item.rateQuote !== undefined
|
|
||||||
);
|
|
||||||
|
|
||||||
// Convert to DTOs
|
// Convert to DTOs
|
||||||
const bookingDtos = BookingMapper.toListItemDtoArray(bookingsWithQuotes);
|
const bookingDtos = BookingMapper.toListItemDtoArray(bookingsWithQuotes);
|
||||||
|
|
||||||
@ -446,21 +440,14 @@ export class BookingsController {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Map ORM entities to domain and fetch rate quotes
|
// Map ORM entities to domain and fetch rate quotes
|
||||||
const bookingsWithQuotesRaw = await Promise.all(
|
const bookingsWithQuotes = await Promise.all(
|
||||||
bookingOrms.map(async bookingOrm => {
|
bookingOrms.map(async bookingOrm => {
|
||||||
const booking = await this.bookingRepository.findById(bookingOrm.id);
|
const booking = await this.bookingRepository.findById(bookingOrm.id);
|
||||||
const rateQuote = await this.rateQuoteRepository.findById(bookingOrm.rateQuoteId);
|
const rateQuote = await this.rateQuoteRepository.findById(bookingOrm.rateQuoteId);
|
||||||
return { booking, rateQuote };
|
return { booking: booking!, rateQuote: rateQuote! };
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// Filter out bookings or rate quotes that are null
|
|
||||||
const bookingsWithQuotes = bookingsWithQuotesRaw.filter(
|
|
||||||
(item): item is { booking: NonNullable<typeof item.booking>; rateQuote: NonNullable<typeof item.rateQuote> } =>
|
|
||||||
item.booking !== null && item.booking !== undefined &&
|
|
||||||
item.rateQuote !== null && item.rateQuote !== undefined
|
|
||||||
);
|
|
||||||
|
|
||||||
// Convert to DTOs
|
// Convert to DTOs
|
||||||
const bookingDtos = bookingsWithQuotes.map(({ booking, rateQuote }) =>
|
const bookingDtos = bookingsWithQuotes.map(({ booking, rateQuote }) =>
|
||||||
BookingMapper.toDto(booking, rateQuote)
|
BookingMapper.toDto(booking, rateQuote)
|
||||||
@ -500,10 +487,8 @@ export class BookingsController {
|
|||||||
// Apply filters
|
// Apply filters
|
||||||
bookings = this.applyFilters(bookings, filter);
|
bookings = this.applyFilters(bookings, filter);
|
||||||
|
|
||||||
// Sort bookings (use defaults if not provided)
|
// Sort bookings
|
||||||
const sortBy = filter.sortBy || 'createdAt';
|
bookings = this.sortBookings(bookings, filter.sortBy!, filter.sortOrder!);
|
||||||
const sortOrder = filter.sortOrder || 'desc';
|
|
||||||
bookings = this.sortBookings(bookings, sortBy, sortOrder);
|
|
||||||
|
|
||||||
// Total count before pagination
|
// Total count before pagination
|
||||||
const total = bookings.length;
|
const total = bookings.length;
|
||||||
@ -513,20 +498,14 @@ export class BookingsController {
|
|||||||
const endIndex = startIndex + (filter.pageSize || 20);
|
const endIndex = startIndex + (filter.pageSize || 20);
|
||||||
const paginatedBookings = bookings.slice(startIndex, endIndex);
|
const paginatedBookings = bookings.slice(startIndex, endIndex);
|
||||||
|
|
||||||
// Fetch rate quotes (filter out those with missing rate quotes)
|
// Fetch rate quotes
|
||||||
const bookingsWithQuotesRaw = await Promise.all(
|
const bookingsWithQuotes = await Promise.all(
|
||||||
paginatedBookings.map(async booking => {
|
paginatedBookings.map(async booking => {
|
||||||
const rateQuote = await this.rateQuoteRepository.findById(booking.rateQuoteId);
|
const rateQuote = await this.rateQuoteRepository.findById(booking.rateQuoteId);
|
||||||
return { booking, rateQuote };
|
return { booking, rateQuote: rateQuote! };
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// Filter out bookings with missing rate quotes to avoid null pointer errors
|
|
||||||
const bookingsWithQuotes = bookingsWithQuotesRaw.filter(
|
|
||||||
(item): item is { booking: any; rateQuote: NonNullable<typeof item.rateQuote> } =>
|
|
||||||
item.rateQuote !== null && item.rateQuote !== undefined
|
|
||||||
);
|
|
||||||
|
|
||||||
// Convert to DTOs
|
// Convert to DTOs
|
||||||
const bookingDtos = BookingMapper.toListItemDtoArray(bookingsWithQuotes);
|
const bookingDtos = BookingMapper.toListItemDtoArray(bookingsWithQuotes);
|
||||||
|
|
||||||
@ -583,20 +562,14 @@ export class BookingsController {
|
|||||||
bookings = this.applyFilters(bookings, filter);
|
bookings = this.applyFilters(bookings, filter);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch rate quotes (filter out those with missing rate quotes)
|
// Fetch rate quotes
|
||||||
const bookingsWithQuotesRaw = await Promise.all(
|
const bookingsWithQuotes = await Promise.all(
|
||||||
bookings.map(async booking => {
|
bookings.map(async booking => {
|
||||||
const rateQuote = await this.rateQuoteRepository.findById(booking.rateQuoteId);
|
const rateQuote = await this.rateQuoteRepository.findById(booking.rateQuoteId);
|
||||||
return { booking, rateQuote };
|
return { booking, rateQuote: rateQuote! };
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// Filter out bookings with missing rate quotes to avoid null pointer errors
|
|
||||||
const bookingsWithQuotes = bookingsWithQuotesRaw.filter(
|
|
||||||
(item): item is { booking: any; rateQuote: NonNullable<typeof item.rateQuote> } =>
|
|
||||||
item.rateQuote !== null && item.rateQuote !== undefined
|
|
||||||
);
|
|
||||||
|
|
||||||
// Generate export file
|
// Generate export file
|
||||||
const exportResult = await this.exportService.exportBookings(
|
const exportResult = await this.exportService.exportBookings(
|
||||||
bookingsWithQuotes,
|
bookingsWithQuotes,
|
||||||
|
|||||||
@ -3,7 +3,6 @@ import {
|
|||||||
Post,
|
Post,
|
||||||
Get,
|
Get,
|
||||||
Patch,
|
Patch,
|
||||||
Delete,
|
|
||||||
Body,
|
Body,
|
||||||
Param,
|
Param,
|
||||||
Query,
|
Query,
|
||||||
@ -40,20 +39,12 @@ import {
|
|||||||
* CSV Bookings Controller
|
* CSV Bookings Controller
|
||||||
*
|
*
|
||||||
* Handles HTTP requests for CSV-based booking requests
|
* Handles HTTP requests for CSV-based booking requests
|
||||||
*
|
|
||||||
* IMPORTANT: Route order matters in NestJS!
|
|
||||||
* Static routes MUST come BEFORE parameterized routes.
|
|
||||||
* Otherwise, `:id` will capture "stats", "organization", etc.
|
|
||||||
*/
|
*/
|
||||||
@ApiTags('CSV Bookings')
|
@ApiTags('CSV Bookings')
|
||||||
@Controller('csv-bookings')
|
@Controller('csv-bookings')
|
||||||
export class CsvBookingsController {
|
export class CsvBookingsController {
|
||||||
constructor(private readonly csvBookingService: CsvBookingService) {}
|
constructor(private readonly csvBookingService: CsvBookingService) {}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// STATIC ROUTES (must come FIRST)
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new CSV booking request
|
* Create a new CSV booking request
|
||||||
*
|
*
|
||||||
@ -160,112 +151,6 @@ export class CsvBookingsController {
|
|||||||
return await this.csvBookingService.createBooking(sanitizedDto, files, userId, organizationId);
|
return await this.csvBookingService.createBooking(sanitizedDto, files, userId, organizationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get current user's bookings (paginated)
|
|
||||||
*
|
|
||||||
* GET /api/v1/csv-bookings
|
|
||||||
*/
|
|
||||||
@Get()
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Get user bookings',
|
|
||||||
description: 'Retrieve all bookings for the authenticated user with pagination.',
|
|
||||||
})
|
|
||||||
@ApiQuery({ name: 'page', required: false, type: Number, example: 1 })
|
|
||||||
@ApiQuery({ name: 'limit', required: false, type: Number, example: 10 })
|
|
||||||
@ApiResponse({
|
|
||||||
status: 200,
|
|
||||||
description: 'Bookings retrieved successfully',
|
|
||||||
type: CsvBookingListResponseDto,
|
|
||||||
})
|
|
||||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
|
||||||
async getUserBookings(
|
|
||||||
@Request() req: any,
|
|
||||||
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
|
|
||||||
@Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number
|
|
||||||
): Promise<CsvBookingListResponseDto> {
|
|
||||||
const userId = req.user.id;
|
|
||||||
return await this.csvBookingService.getUserBookings(userId, page, limit);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get booking statistics for user
|
|
||||||
*
|
|
||||||
* GET /api/v1/csv-bookings/stats/me
|
|
||||||
*/
|
|
||||||
@Get('stats/me')
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Get user booking statistics',
|
|
||||||
description:
|
|
||||||
'Get aggregated statistics for the authenticated user (pending, accepted, rejected, cancelled).',
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: 200,
|
|
||||||
description: 'Statistics retrieved successfully',
|
|
||||||
type: CsvBookingStatsDto,
|
|
||||||
})
|
|
||||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
|
||||||
async getUserStats(@Request() req: any): Promise<CsvBookingStatsDto> {
|
|
||||||
const userId = req.user.id;
|
|
||||||
return await this.csvBookingService.getUserStats(userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get organization booking statistics
|
|
||||||
*
|
|
||||||
* GET /api/v1/csv-bookings/stats/organization
|
|
||||||
*/
|
|
||||||
@Get('stats/organization')
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Get organization booking statistics',
|
|
||||||
description: "Get aggregated statistics for the user's organization. For managers/admins.",
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: 200,
|
|
||||||
description: 'Statistics retrieved successfully',
|
|
||||||
type: CsvBookingStatsDto,
|
|
||||||
})
|
|
||||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
|
||||||
async getOrganizationStats(@Request() req: any): Promise<CsvBookingStatsDto> {
|
|
||||||
const organizationId = req.user.organizationId;
|
|
||||||
return await this.csvBookingService.getOrganizationStats(organizationId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get organization bookings (for managers/admins)
|
|
||||||
*
|
|
||||||
* GET /api/v1/csv-bookings/organization/all
|
|
||||||
*/
|
|
||||||
@Get('organization/all')
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Get organization bookings',
|
|
||||||
description:
|
|
||||||
"Retrieve all bookings for the user's organization with pagination. For managers/admins.",
|
|
||||||
})
|
|
||||||
@ApiQuery({ name: 'page', required: false, type: Number, example: 1 })
|
|
||||||
@ApiQuery({ name: 'limit', required: false, type: Number, example: 10 })
|
|
||||||
@ApiResponse({
|
|
||||||
status: 200,
|
|
||||||
description: 'Organization bookings retrieved successfully',
|
|
||||||
type: CsvBookingListResponseDto,
|
|
||||||
})
|
|
||||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
|
||||||
async getOrganizationBookings(
|
|
||||||
@Request() req: any,
|
|
||||||
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
|
|
||||||
@Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number
|
|
||||||
): Promise<CsvBookingListResponseDto> {
|
|
||||||
const organizationId = req.user.organizationId;
|
|
||||||
return await this.csvBookingService.getOrganizationBookings(organizationId, page, limit);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Accept a booking request (PUBLIC - token-based)
|
* Accept a booking request (PUBLIC - token-based)
|
||||||
*
|
*
|
||||||
@ -341,17 +226,10 @@ export class CsvBookingsController {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// PARAMETERIZED ROUTES (must come LAST)
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a booking by ID
|
* Get a booking by ID
|
||||||
*
|
*
|
||||||
* GET /api/v1/csv-bookings/:id
|
* GET /api/v1/csv-bookings/:id
|
||||||
*
|
|
||||||
* IMPORTANT: This route MUST be after all static GET routes
|
|
||||||
* Otherwise it will capture "stats", "organization", etc.
|
|
||||||
*/
|
*/
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@ -374,6 +252,59 @@ export class CsvBookingsController {
|
|||||||
return await this.csvBookingService.getBookingById(id, userId, carrierId);
|
return await this.csvBookingService.getBookingById(id, userId, carrierId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current user's bookings (paginated)
|
||||||
|
*
|
||||||
|
* GET /api/v1/csv-bookings
|
||||||
|
*/
|
||||||
|
@Get()
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Get user bookings',
|
||||||
|
description: 'Retrieve all bookings for the authenticated user with pagination.',
|
||||||
|
})
|
||||||
|
@ApiQuery({ name: 'page', required: false, type: Number, example: 1 })
|
||||||
|
@ApiQuery({ name: 'limit', required: false, type: Number, example: 10 })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'Bookings retrieved successfully',
|
||||||
|
type: CsvBookingListResponseDto,
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||||
|
async getUserBookings(
|
||||||
|
@Request() req: any,
|
||||||
|
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
|
||||||
|
@Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number
|
||||||
|
): Promise<CsvBookingListResponseDto> {
|
||||||
|
const userId = req.user.id;
|
||||||
|
return await this.csvBookingService.getUserBookings(userId, page, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get booking statistics for user
|
||||||
|
*
|
||||||
|
* GET /api/v1/csv-bookings/stats/me
|
||||||
|
*/
|
||||||
|
@Get('stats/me')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Get user booking statistics',
|
||||||
|
description:
|
||||||
|
'Get aggregated statistics for the authenticated user (pending, accepted, rejected, cancelled).',
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'Statistics retrieved successfully',
|
||||||
|
type: CsvBookingStatsDto,
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||||
|
async getUserStats(@Request() req: any): Promise<CsvBookingStatsDto> {
|
||||||
|
const userId = req.user.id;
|
||||||
|
return await this.csvBookingService.getUserStats(userId);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cancel a booking (user action)
|
* Cancel a booking (user action)
|
||||||
*
|
*
|
||||||
@ -404,165 +335,55 @@ export class CsvBookingsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add documents to an existing booking
|
* Get organization bookings (for managers/admins)
|
||||||
*
|
*
|
||||||
* POST /api/v1/csv-bookings/:id/documents
|
* GET /api/v1/csv-bookings/organization/all
|
||||||
*/
|
*/
|
||||||
@Post(':id/documents')
|
@Get('organization/all')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@UseInterceptors(FilesInterceptor('documents', 10))
|
|
||||||
@ApiConsumes('multipart/form-data')
|
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Add documents to an existing booking',
|
summary: 'Get organization bookings',
|
||||||
description:
|
description:
|
||||||
'Upload additional documents to a pending booking. Only the booking owner can add documents.',
|
"Retrieve all bookings for the user's organization with pagination. For managers/admins.",
|
||||||
})
|
|
||||||
@ApiParam({ name: 'id', description: 'Booking ID (UUID)' })
|
|
||||||
@ApiBody({
|
|
||||||
schema: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
documents: {
|
|
||||||
type: 'array',
|
|
||||||
items: { type: 'string', format: 'binary' },
|
|
||||||
description: 'Documents to add (max 10 files)',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
@ApiQuery({ name: 'page', required: false, type: Number, example: 1 })
|
||||||
|
@ApiQuery({ name: 'limit', required: false, type: Number, example: 10 })
|
||||||
@ApiResponse({
|
@ApiResponse({
|
||||||
status: 200,
|
status: 200,
|
||||||
description: 'Documents added successfully',
|
description: 'Organization bookings retrieved successfully',
|
||||||
schema: {
|
type: CsvBookingListResponseDto,
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
success: { type: 'boolean', example: true },
|
|
||||||
message: { type: 'string', example: 'Documents added successfully' },
|
|
||||||
documentsAdded: { type: 'number', example: 2 },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
@ApiResponse({ status: 400, description: 'Invalid request or booking cannot be modified' })
|
|
||||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||||
@ApiResponse({ status: 404, description: 'Booking not found' })
|
async getOrganizationBookings(
|
||||||
async addDocuments(
|
@Request() req: any,
|
||||||
@Param('id') id: string,
|
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
|
||||||
@UploadedFiles() files: Express.Multer.File[],
|
@Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number
|
||||||
@Request() req: any
|
): Promise<CsvBookingListResponseDto> {
|
||||||
) {
|
const organizationId = req.user.organizationId;
|
||||||
if (!files || files.length === 0) {
|
return await this.csvBookingService.getOrganizationBookings(organizationId, page, limit);
|
||||||
throw new BadRequestException('At least one document is required');
|
|
||||||
}
|
|
||||||
|
|
||||||
const userId = req.user.id;
|
|
||||||
return await this.csvBookingService.addDocuments(id, files, userId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Replace a document in a booking
|
* Get organization booking statistics
|
||||||
*
|
*
|
||||||
* PUT /api/v1/csv-bookings/:bookingId/documents/:documentId
|
* GET /api/v1/csv-bookings/stats/organization
|
||||||
*/
|
*/
|
||||||
@Patch(':bookingId/documents/:documentId')
|
@Get('stats/organization')
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@UseInterceptors(FilesInterceptor('document', 1))
|
|
||||||
@ApiConsumes('multipart/form-data')
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Replace a document in a booking',
|
|
||||||
description:
|
|
||||||
'Replace an existing document with a new one. Only the booking owner can replace documents.',
|
|
||||||
})
|
|
||||||
@ApiParam({ name: 'bookingId', description: 'Booking ID (UUID)' })
|
|
||||||
@ApiParam({ name: 'documentId', description: 'Document ID (UUID) to replace' })
|
|
||||||
@ApiBody({
|
|
||||||
schema: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
document: {
|
|
||||||
type: 'string',
|
|
||||||
format: 'binary',
|
|
||||||
description: 'New document file to replace the existing one',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: 200,
|
|
||||||
description: 'Document replaced successfully',
|
|
||||||
schema: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
success: { type: 'boolean', example: true },
|
|
||||||
message: { type: 'string', example: 'Document replaced successfully' },
|
|
||||||
newDocument: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
id: { type: 'string' },
|
|
||||||
type: { type: 'string' },
|
|
||||||
fileName: { type: 'string' },
|
|
||||||
filePath: { type: 'string' },
|
|
||||||
mimeType: { type: 'string' },
|
|
||||||
size: { type: 'number' },
|
|
||||||
uploadedAt: { type: 'string', format: 'date-time' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@ApiResponse({ status: 400, description: 'Invalid request - missing file' })
|
|
||||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
|
||||||
@ApiResponse({ status: 404, description: 'Booking or document not found' })
|
|
||||||
async replaceDocument(
|
|
||||||
@Param('bookingId') bookingId: string,
|
|
||||||
@Param('documentId') documentId: string,
|
|
||||||
@UploadedFiles() files: Express.Multer.File[],
|
|
||||||
@Request() req: any
|
|
||||||
) {
|
|
||||||
if (!files || files.length === 0) {
|
|
||||||
throw new BadRequestException('A document file is required');
|
|
||||||
}
|
|
||||||
|
|
||||||
const userId = req.user.id;
|
|
||||||
return await this.csvBookingService.replaceDocument(bookingId, documentId, files[0], userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a document from a booking
|
|
||||||
*
|
|
||||||
* DELETE /api/v1/csv-bookings/:bookingId/documents/:documentId
|
|
||||||
*/
|
|
||||||
@Delete(':bookingId/documents/:documentId')
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Delete a document from a booking',
|
summary: 'Get organization booking statistics',
|
||||||
description:
|
description: "Get aggregated statistics for the user's organization. For managers/admins.",
|
||||||
'Remove a document from a pending booking. Only the booking owner can delete documents.',
|
|
||||||
})
|
})
|
||||||
@ApiParam({ name: 'bookingId', description: 'Booking ID (UUID)' })
|
|
||||||
@ApiParam({ name: 'documentId', description: 'Document ID (UUID)' })
|
|
||||||
@ApiResponse({
|
@ApiResponse({
|
||||||
status: 200,
|
status: 200,
|
||||||
description: 'Document deleted successfully',
|
description: 'Statistics retrieved successfully',
|
||||||
schema: {
|
type: CsvBookingStatsDto,
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
success: { type: 'boolean', example: true },
|
|
||||||
message: { type: 'string', example: 'Document deleted successfully' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
@ApiResponse({ status: 400, description: 'Booking cannot be modified (not pending)' })
|
|
||||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||||
@ApiResponse({ status: 404, description: 'Booking or document not found' })
|
async getOrganizationStats(@Request() req: any): Promise<CsvBookingStatsDto> {
|
||||||
async deleteDocument(
|
const organizationId = req.user.organizationId;
|
||||||
@Param('bookingId') bookingId: string,
|
return await this.csvBookingService.getOrganizationStats(organizationId);
|
||||||
@Param('documentId') documentId: string,
|
|
||||||
@Request() req: any
|
|
||||||
) {
|
|
||||||
const userId = req.user.id;
|
|
||||||
return await this.csvBookingService.deleteDocument(bookingId, documentId, userId);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,266 +0,0 @@
|
|||||||
/**
|
|
||||||
* Subscriptions Controller
|
|
||||||
*
|
|
||||||
* Handles subscription management endpoints:
|
|
||||||
* - GET /subscriptions - Get subscription overview
|
|
||||||
* - GET /subscriptions/plans - Get all available plans
|
|
||||||
* - GET /subscriptions/can-invite - Check if can invite users
|
|
||||||
* - POST /subscriptions/checkout - Create Stripe checkout session
|
|
||||||
* - POST /subscriptions/portal - Create Stripe portal session
|
|
||||||
* - POST /subscriptions/webhook - Handle Stripe webhooks
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {
|
|
||||||
Controller,
|
|
||||||
Get,
|
|
||||||
Post,
|
|
||||||
Body,
|
|
||||||
UseGuards,
|
|
||||||
HttpCode,
|
|
||||||
HttpStatus,
|
|
||||||
Logger,
|
|
||||||
Headers,
|
|
||||||
RawBodyRequest,
|
|
||||||
Req,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import {
|
|
||||||
ApiTags,
|
|
||||||
ApiOperation,
|
|
||||||
ApiResponse,
|
|
||||||
ApiBearerAuth,
|
|
||||||
ApiExcludeEndpoint,
|
|
||||||
} from '@nestjs/swagger';
|
|
||||||
import { Request } from 'express';
|
|
||||||
import { SubscriptionService } from '../services/subscription.service';
|
|
||||||
import {
|
|
||||||
CreateCheckoutSessionDto,
|
|
||||||
CreatePortalSessionDto,
|
|
||||||
SyncSubscriptionDto,
|
|
||||||
SubscriptionOverviewResponseDto,
|
|
||||||
CanInviteResponseDto,
|
|
||||||
CheckoutSessionResponseDto,
|
|
||||||
PortalSessionResponseDto,
|
|
||||||
AllPlansResponseDto,
|
|
||||||
} from '../dto/subscription.dto';
|
|
||||||
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
|
||||||
import { RolesGuard } from '../guards/roles.guard';
|
|
||||||
import { Roles } from '../decorators/roles.decorator';
|
|
||||||
import { CurrentUser, UserPayload } from '../decorators/current-user.decorator';
|
|
||||||
import { Public } from '../decorators/public.decorator';
|
|
||||||
|
|
||||||
@ApiTags('Subscriptions')
|
|
||||||
@Controller('subscriptions')
|
|
||||||
export class SubscriptionsController {
|
|
||||||
private readonly logger = new Logger(SubscriptionsController.name);
|
|
||||||
|
|
||||||
constructor(private readonly subscriptionService: SubscriptionService) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get subscription overview for current organization
|
|
||||||
*/
|
|
||||||
@Get()
|
|
||||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
|
||||||
@Roles('admin', 'manager')
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Get subscription overview',
|
|
||||||
description:
|
|
||||||
'Get the subscription details including licenses for the current organization. Admin/manager only.',
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: 200,
|
|
||||||
description: 'Subscription overview retrieved successfully',
|
|
||||||
type: SubscriptionOverviewResponseDto,
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: 403,
|
|
||||||
description: 'Forbidden - requires admin or manager role',
|
|
||||||
})
|
|
||||||
async getSubscriptionOverview(
|
|
||||||
@CurrentUser() user: UserPayload,
|
|
||||||
): Promise<SubscriptionOverviewResponseDto> {
|
|
||||||
this.logger.log(`[User: ${user.email}] Getting subscription overview`);
|
|
||||||
return this.subscriptionService.getSubscriptionOverview(user.organizationId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all available plans
|
|
||||||
*/
|
|
||||||
@Get('plans')
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Get all plans',
|
|
||||||
description: 'Get details of all available subscription plans.',
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: 200,
|
|
||||||
description: 'Plans retrieved successfully',
|
|
||||||
type: AllPlansResponseDto,
|
|
||||||
})
|
|
||||||
getAllPlans(): AllPlansResponseDto {
|
|
||||||
this.logger.log('Getting all subscription plans');
|
|
||||||
return this.subscriptionService.getAllPlans();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if organization can invite more users
|
|
||||||
*/
|
|
||||||
@Get('can-invite')
|
|
||||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
|
||||||
@Roles('admin', 'manager')
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Check license availability',
|
|
||||||
description:
|
|
||||||
'Check if the organization can invite more users based on license availability. Admin/manager only.',
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: 200,
|
|
||||||
description: 'License availability check result',
|
|
||||||
type: CanInviteResponseDto,
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: 403,
|
|
||||||
description: 'Forbidden - requires admin or manager role',
|
|
||||||
})
|
|
||||||
async canInvite(@CurrentUser() user: UserPayload): Promise<CanInviteResponseDto> {
|
|
||||||
this.logger.log(`[User: ${user.email}] Checking license availability`);
|
|
||||||
return this.subscriptionService.canInviteUser(user.organizationId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create Stripe Checkout session for subscription upgrade
|
|
||||||
*/
|
|
||||||
@Post('checkout')
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
|
||||||
@Roles('admin', 'manager')
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Create checkout session',
|
|
||||||
description:
|
|
||||||
'Create a Stripe Checkout session for upgrading subscription. Admin/Manager only.',
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: 200,
|
|
||||||
description: 'Checkout session created successfully',
|
|
||||||
type: CheckoutSessionResponseDto,
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: 400,
|
|
||||||
description: 'Bad request - invalid plan or already subscribed',
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: 403,
|
|
||||||
description: 'Forbidden - requires admin or manager role',
|
|
||||||
})
|
|
||||||
async createCheckoutSession(
|
|
||||||
@Body() dto: CreateCheckoutSessionDto,
|
|
||||||
@CurrentUser() user: UserPayload,
|
|
||||||
): Promise<CheckoutSessionResponseDto> {
|
|
||||||
this.logger.log(`[User: ${user.email}] Creating checkout session for plan: ${dto.plan}`);
|
|
||||||
return this.subscriptionService.createCheckoutSession(
|
|
||||||
user.organizationId,
|
|
||||||
user.id,
|
|
||||||
dto,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create Stripe Customer Portal session
|
|
||||||
*/
|
|
||||||
@Post('portal')
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
|
||||||
@Roles('admin', 'manager')
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Create portal session',
|
|
||||||
description:
|
|
||||||
'Create a Stripe Customer Portal session for subscription management. Admin/Manager only.',
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: 200,
|
|
||||||
description: 'Portal session created successfully',
|
|
||||||
type: PortalSessionResponseDto,
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: 400,
|
|
||||||
description: 'Bad request - no Stripe customer found',
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: 403,
|
|
||||||
description: 'Forbidden - requires admin or manager role',
|
|
||||||
})
|
|
||||||
async createPortalSession(
|
|
||||||
@Body() dto: CreatePortalSessionDto,
|
|
||||||
@CurrentUser() user: UserPayload,
|
|
||||||
): Promise<PortalSessionResponseDto> {
|
|
||||||
this.logger.log(`[User: ${user.email}] Creating portal session`);
|
|
||||||
return this.subscriptionService.createPortalSession(user.organizationId, dto);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sync subscription from Stripe
|
|
||||||
* Useful when webhooks are not available (e.g., local development)
|
|
||||||
*/
|
|
||||||
@Post('sync')
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
|
||||||
@Roles('admin', 'manager')
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Sync subscription from Stripe',
|
|
||||||
description:
|
|
||||||
'Manually sync subscription data from Stripe. Useful when webhooks are not working (local dev). Pass sessionId after checkout to sync new subscription. Admin/Manager only.',
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: 200,
|
|
||||||
description: 'Subscription synced successfully',
|
|
||||||
type: SubscriptionOverviewResponseDto,
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: 400,
|
|
||||||
description: 'Bad request - no Stripe subscription found',
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: 403,
|
|
||||||
description: 'Forbidden - requires admin or manager role',
|
|
||||||
})
|
|
||||||
async syncFromStripe(
|
|
||||||
@Body() dto: SyncSubscriptionDto,
|
|
||||||
@CurrentUser() user: UserPayload,
|
|
||||||
): Promise<SubscriptionOverviewResponseDto> {
|
|
||||||
this.logger.log(
|
|
||||||
`[User: ${user.email}] Syncing subscription from Stripe${dto.sessionId ? ` (sessionId: ${dto.sessionId})` : ''}`,
|
|
||||||
);
|
|
||||||
return this.subscriptionService.syncFromStripe(user.organizationId, dto.sessionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle Stripe webhook events
|
|
||||||
*/
|
|
||||||
@Post('webhook')
|
|
||||||
@Public()
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@ApiExcludeEndpoint()
|
|
||||||
async handleWebhook(
|
|
||||||
@Headers('stripe-signature') signature: string,
|
|
||||||
@Req() req: RawBodyRequest<Request>,
|
|
||||||
): Promise<{ received: boolean }> {
|
|
||||||
const rawBody = req.rawBody;
|
|
||||||
if (!rawBody) {
|
|
||||||
this.logger.error('No raw body found in request');
|
|
||||||
return { received: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.subscriptionService.handleStripeWebhook(rawBody, signature);
|
|
||||||
return { received: true };
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Webhook processing failed', error);
|
|
||||||
return { received: false };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -49,7 +49,6 @@ import { Roles } from '../decorators/roles.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';
|
||||||
import { SubscriptionService } from '../services/subscription.service';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Users Controller
|
* Users Controller
|
||||||
@ -69,10 +68,7 @@ import { SubscriptionService } from '../services/subscription.service';
|
|||||||
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,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create/Invite a new user
|
* Create/Invite a new user
|
||||||
@ -277,21 +273,8 @@ export class UsersController {
|
|||||||
if (dto.isActive !== undefined) {
|
if (dto.isActive !== undefined) {
|
||||||
if (dto.isActive) {
|
if (dto.isActive) {
|
||||||
user.activate();
|
user.activate();
|
||||||
// Reallocate license if reactivating user
|
|
||||||
try {
|
|
||||||
await this.subscriptionService.allocateLicense(id, user.organizationId);
|
|
||||||
this.logger.log(`License reallocated for reactivated user: ${id}`);
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to reallocate license for user ${id}:`, error);
|
|
||||||
throw new ForbiddenException(
|
|
||||||
'Cannot reactivate user: no licenses available. Please upgrade your subscription.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
user.deactivate();
|
user.deactivate();
|
||||||
// Revoke license when deactivating user
|
|
||||||
await this.subscriptionService.revokeLicense(id);
|
|
||||||
this.logger.log(`License revoked for deactivated user: ${id}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -338,10 +321,6 @@ export class UsersController {
|
|||||||
throw new NotFoundException(`User ${id} not found`);
|
throw new NotFoundException(`User ${id} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Revoke license before deleting user
|
|
||||||
await this.subscriptionService.revokeLicense(id);
|
|
||||||
this.logger.log(`License revoked for user being deleted: ${id}`);
|
|
||||||
|
|
||||||
// Permanently delete user from database
|
// Permanently delete user from database
|
||||||
await this.userRepository.deleteById(id);
|
await this.userRepository.deleteById(id);
|
||||||
|
|
||||||
|
|||||||
@ -1,378 +0,0 @@
|
|||||||
/**
|
|
||||||
* Subscription DTOs
|
|
||||||
*
|
|
||||||
* Data Transfer Objects for subscription management API
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
|
||||||
import {
|
|
||||||
IsString,
|
|
||||||
IsEnum,
|
|
||||||
IsNotEmpty,
|
|
||||||
IsUrl,
|
|
||||||
IsOptional,
|
|
||||||
IsBoolean,
|
|
||||||
IsInt,
|
|
||||||
Min,
|
|
||||||
} from 'class-validator';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Subscription plan types
|
|
||||||
*/
|
|
||||||
export enum SubscriptionPlanDto {
|
|
||||||
FREE = 'FREE',
|
|
||||||
STARTER = 'STARTER',
|
|
||||||
PRO = 'PRO',
|
|
||||||
ENTERPRISE = 'ENTERPRISE',
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Subscription status types
|
|
||||||
*/
|
|
||||||
export enum SubscriptionStatusDto {
|
|
||||||
ACTIVE = 'ACTIVE',
|
|
||||||
PAST_DUE = 'PAST_DUE',
|
|
||||||
CANCELED = 'CANCELED',
|
|
||||||
INCOMPLETE = 'INCOMPLETE',
|
|
||||||
INCOMPLETE_EXPIRED = 'INCOMPLETE_EXPIRED',
|
|
||||||
TRIALING = 'TRIALING',
|
|
||||||
UNPAID = 'UNPAID',
|
|
||||||
PAUSED = 'PAUSED',
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Billing interval types
|
|
||||||
*/
|
|
||||||
export enum BillingIntervalDto {
|
|
||||||
MONTHLY = 'monthly',
|
|
||||||
YEARLY = 'yearly',
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create Checkout Session DTO
|
|
||||||
*/
|
|
||||||
export class CreateCheckoutSessionDto {
|
|
||||||
@ApiProperty({
|
|
||||||
example: SubscriptionPlanDto.STARTER,
|
|
||||||
description: 'The subscription plan to purchase',
|
|
||||||
enum: SubscriptionPlanDto,
|
|
||||||
})
|
|
||||||
@IsEnum(SubscriptionPlanDto)
|
|
||||||
plan: SubscriptionPlanDto;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
example: BillingIntervalDto.MONTHLY,
|
|
||||||
description: 'Billing interval (monthly or yearly)',
|
|
||||||
enum: BillingIntervalDto,
|
|
||||||
})
|
|
||||||
@IsEnum(BillingIntervalDto)
|
|
||||||
billingInterval: BillingIntervalDto;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
example: 'https://app.xpeditis.com/dashboard/settings/subscription?success=true',
|
|
||||||
description: 'URL to redirect to after successful payment',
|
|
||||||
})
|
|
||||||
@IsUrl()
|
|
||||||
@IsOptional()
|
|
||||||
successUrl?: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
example: 'https://app.xpeditis.com/dashboard/settings/subscription?canceled=true',
|
|
||||||
description: 'URL to redirect to if payment is canceled',
|
|
||||||
})
|
|
||||||
@IsUrl()
|
|
||||||
@IsOptional()
|
|
||||||
cancelUrl?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create Portal Session DTO
|
|
||||||
*/
|
|
||||||
export class CreatePortalSessionDto {
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
example: 'https://app.xpeditis.com/dashboard/settings/subscription',
|
|
||||||
description: 'URL to return to after using the portal',
|
|
||||||
})
|
|
||||||
@IsUrl()
|
|
||||||
@IsOptional()
|
|
||||||
returnUrl?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sync Subscription DTO
|
|
||||||
*/
|
|
||||||
export class SyncSubscriptionDto {
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
example: 'cs_test_a1b2c3d4e5f6g7h8',
|
|
||||||
description: 'Stripe checkout session ID (used after checkout completes)',
|
|
||||||
})
|
|
||||||
@IsString()
|
|
||||||
@IsOptional()
|
|
||||||
sessionId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checkout Session Response DTO
|
|
||||||
*/
|
|
||||||
export class CheckoutSessionResponseDto {
|
|
||||||
@ApiProperty({
|
|
||||||
example: 'cs_test_a1b2c3d4e5f6g7h8',
|
|
||||||
description: 'Stripe checkout session ID',
|
|
||||||
})
|
|
||||||
sessionId: string;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
example: 'https://checkout.stripe.com/pay/cs_test_a1b2c3',
|
|
||||||
description: 'URL to redirect user to for payment',
|
|
||||||
})
|
|
||||||
sessionUrl: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Portal Session Response DTO
|
|
||||||
*/
|
|
||||||
export class PortalSessionResponseDto {
|
|
||||||
@ApiProperty({
|
|
||||||
example: 'https://billing.stripe.com/session/test_YWNjdF8x',
|
|
||||||
description: 'URL to redirect user to for subscription management',
|
|
||||||
})
|
|
||||||
sessionUrl: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* License Response DTO
|
|
||||||
*/
|
|
||||||
export class LicenseResponseDto {
|
|
||||||
@ApiProperty({
|
|
||||||
example: '550e8400-e29b-41d4-a716-446655440000',
|
|
||||||
description: 'License ID',
|
|
||||||
})
|
|
||||||
id: string;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
example: '550e8400-e29b-41d4-a716-446655440001',
|
|
||||||
description: 'User ID',
|
|
||||||
})
|
|
||||||
userId: string;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
example: 'john.doe@example.com',
|
|
||||||
description: 'User email',
|
|
||||||
})
|
|
||||||
userEmail: string;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
example: 'John Doe',
|
|
||||||
description: 'User full name',
|
|
||||||
})
|
|
||||||
userName: string;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
example: 'ADMIN',
|
|
||||||
description: 'User role (ADMIN users have unlimited licenses)',
|
|
||||||
})
|
|
||||||
userRole: string;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
example: 'ACTIVE',
|
|
||||||
description: 'License status',
|
|
||||||
})
|
|
||||||
status: string;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
example: '2025-01-15T10:00:00Z',
|
|
||||||
description: 'When the license was assigned',
|
|
||||||
})
|
|
||||||
assignedAt: Date;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
example: '2025-02-15T10:00:00Z',
|
|
||||||
description: 'When the license was revoked (if applicable)',
|
|
||||||
})
|
|
||||||
revokedAt?: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Plan Details DTO
|
|
||||||
*/
|
|
||||||
export class PlanDetailsDto {
|
|
||||||
@ApiProperty({
|
|
||||||
example: SubscriptionPlanDto.STARTER,
|
|
||||||
description: 'Plan identifier',
|
|
||||||
enum: SubscriptionPlanDto,
|
|
||||||
})
|
|
||||||
plan: SubscriptionPlanDto;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
example: 'Starter',
|
|
||||||
description: 'Plan display name',
|
|
||||||
})
|
|
||||||
name: string;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
example: 5,
|
|
||||||
description: 'Maximum number of licenses (-1 for unlimited)',
|
|
||||||
})
|
|
||||||
maxLicenses: number;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
example: 49,
|
|
||||||
description: 'Monthly price in EUR',
|
|
||||||
})
|
|
||||||
monthlyPriceEur: number;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
example: 470,
|
|
||||||
description: 'Yearly price in EUR',
|
|
||||||
})
|
|
||||||
yearlyPriceEur: number;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
example: ['Up to 5 users', 'Advanced rate search', 'CSV imports'],
|
|
||||||
description: 'List of features included in this plan',
|
|
||||||
type: [String],
|
|
||||||
})
|
|
||||||
features: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Subscription Response DTO
|
|
||||||
*/
|
|
||||||
export class SubscriptionResponseDto {
|
|
||||||
@ApiProperty({
|
|
||||||
example: '550e8400-e29b-41d4-a716-446655440000',
|
|
||||||
description: 'Subscription ID',
|
|
||||||
})
|
|
||||||
id: string;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
example: '550e8400-e29b-41d4-a716-446655440001',
|
|
||||||
description: 'Organization ID',
|
|
||||||
})
|
|
||||||
organizationId: string;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
example: SubscriptionPlanDto.STARTER,
|
|
||||||
description: 'Current subscription plan',
|
|
||||||
enum: SubscriptionPlanDto,
|
|
||||||
})
|
|
||||||
plan: SubscriptionPlanDto;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
description: 'Details about the current plan',
|
|
||||||
type: PlanDetailsDto,
|
|
||||||
})
|
|
||||||
planDetails: PlanDetailsDto;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
example: SubscriptionStatusDto.ACTIVE,
|
|
||||||
description: 'Current subscription status',
|
|
||||||
enum: SubscriptionStatusDto,
|
|
||||||
})
|
|
||||||
status: SubscriptionStatusDto;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
example: 3,
|
|
||||||
description: 'Number of licenses currently in use',
|
|
||||||
})
|
|
||||||
usedLicenses: number;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
example: 5,
|
|
||||||
description: 'Maximum licenses available (-1 for unlimited)',
|
|
||||||
})
|
|
||||||
maxLicenses: number;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
example: 2,
|
|
||||||
description: 'Number of licenses available',
|
|
||||||
})
|
|
||||||
availableLicenses: number;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
example: false,
|
|
||||||
description: 'Whether the subscription is scheduled for cancellation',
|
|
||||||
})
|
|
||||||
cancelAtPeriodEnd: boolean;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
example: '2025-01-01T00:00:00Z',
|
|
||||||
description: 'Start of current billing period',
|
|
||||||
})
|
|
||||||
currentPeriodStart?: Date;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
example: '2025-02-01T00:00:00Z',
|
|
||||||
description: 'End of current billing period',
|
|
||||||
})
|
|
||||||
currentPeriodEnd?: Date;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
example: '2025-01-01T00:00:00Z',
|
|
||||||
description: 'When the subscription was created',
|
|
||||||
})
|
|
||||||
createdAt: Date;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
example: '2025-01-15T10:00:00Z',
|
|
||||||
description: 'When the subscription was last updated',
|
|
||||||
})
|
|
||||||
updatedAt: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Subscription Overview Response DTO (includes licenses)
|
|
||||||
*/
|
|
||||||
export class SubscriptionOverviewResponseDto extends SubscriptionResponseDto {
|
|
||||||
@ApiProperty({
|
|
||||||
description: 'List of active licenses',
|
|
||||||
type: [LicenseResponseDto],
|
|
||||||
})
|
|
||||||
licenses: LicenseResponseDto[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Can Invite Response DTO
|
|
||||||
*/
|
|
||||||
export class CanInviteResponseDto {
|
|
||||||
@ApiProperty({
|
|
||||||
example: true,
|
|
||||||
description: 'Whether the organization can invite more users',
|
|
||||||
})
|
|
||||||
canInvite: boolean;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
example: 2,
|
|
||||||
description: 'Number of available licenses',
|
|
||||||
})
|
|
||||||
availableLicenses: number;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
example: 3,
|
|
||||||
description: 'Number of used licenses',
|
|
||||||
})
|
|
||||||
usedLicenses: number;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
example: 5,
|
|
||||||
description: 'Maximum licenses allowed (-1 for unlimited)',
|
|
||||||
})
|
|
||||||
maxLicenses: number;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
example: 'Upgrade to Starter plan to add more users',
|
|
||||||
description: 'Message explaining why invitations are blocked',
|
|
||||||
})
|
|
||||||
message?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* All Plans Response DTO
|
|
||||||
*/
|
|
||||||
export class AllPlansResponseDto {
|
|
||||||
@ApiProperty({
|
|
||||||
description: 'List of all available plans',
|
|
||||||
type: [PlanDetailsDto],
|
|
||||||
})
|
|
||||||
plans: PlanDetailsDto[];
|
|
||||||
}
|
|
||||||
@ -454,219 +454,6 @@ export class CsvBookingService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Add documents to an existing booking
|
|
||||||
*/
|
|
||||||
async addDocuments(
|
|
||||||
bookingId: string,
|
|
||||||
files: Express.Multer.File[],
|
|
||||||
userId: string
|
|
||||||
): Promise<{ success: boolean; message: string; documentsAdded: number }> {
|
|
||||||
this.logger.log(`Adding ${files.length} documents to booking ${bookingId}`);
|
|
||||||
|
|
||||||
const booking = await this.csvBookingRepository.findById(bookingId);
|
|
||||||
|
|
||||||
if (!booking) {
|
|
||||||
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify user owns this booking
|
|
||||||
if (booking.userId !== userId) {
|
|
||||||
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify booking is still pending
|
|
||||||
if (booking.status !== CsvBookingStatus.PENDING) {
|
|
||||||
throw new BadRequestException('Cannot add documents to a booking that is not pending');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Upload new documents
|
|
||||||
const newDocuments = await this.uploadDocuments(files, bookingId);
|
|
||||||
|
|
||||||
// Add documents to booking
|
|
||||||
const updatedDocuments = [...booking.documents, ...newDocuments];
|
|
||||||
|
|
||||||
// Update booking in database using ORM repository directly
|
|
||||||
const ormBooking = await this.csvBookingRepository['repository'].findOne({
|
|
||||||
where: { id: bookingId },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (ormBooking) {
|
|
||||||
ormBooking.documents = updatedDocuments.map(doc => ({
|
|
||||||
id: doc.id,
|
|
||||||
type: doc.type,
|
|
||||||
fileName: doc.fileName,
|
|
||||||
filePath: doc.filePath,
|
|
||||||
mimeType: doc.mimeType,
|
|
||||||
size: doc.size,
|
|
||||||
uploadedAt: doc.uploadedAt,
|
|
||||||
}));
|
|
||||||
await this.csvBookingRepository['repository'].save(ormBooking);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.log(`Added ${newDocuments.length} documents to booking ${bookingId}`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: 'Documents added successfully',
|
|
||||||
documentsAdded: newDocuments.length,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a document from a booking
|
|
||||||
*/
|
|
||||||
async deleteDocument(
|
|
||||||
bookingId: string,
|
|
||||||
documentId: string,
|
|
||||||
userId: string
|
|
||||||
): Promise<{ success: boolean; message: string }> {
|
|
||||||
this.logger.log(`Deleting document ${documentId} from booking ${bookingId}`);
|
|
||||||
|
|
||||||
const booking = await this.csvBookingRepository.findById(bookingId);
|
|
||||||
|
|
||||||
if (!booking) {
|
|
||||||
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify user owns this booking
|
|
||||||
if (booking.userId !== userId) {
|
|
||||||
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify booking is still pending
|
|
||||||
if (booking.status !== CsvBookingStatus.PENDING) {
|
|
||||||
throw new BadRequestException('Cannot delete documents from a booking that is not pending');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the document
|
|
||||||
const documentIndex = booking.documents.findIndex(doc => doc.id === documentId);
|
|
||||||
|
|
||||||
if (documentIndex === -1) {
|
|
||||||
throw new NotFoundException(`Document with ID ${documentId} not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure at least one document remains
|
|
||||||
if (booking.documents.length <= 1) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
'Cannot delete the last document. At least one document is required.'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the document to delete (for potential S3 cleanup - currently kept for audit)
|
|
||||||
const _documentToDelete = booking.documents[documentIndex];
|
|
||||||
|
|
||||||
// Remove document from array
|
|
||||||
const updatedDocuments = booking.documents.filter(doc => doc.id !== documentId);
|
|
||||||
|
|
||||||
// Update booking in database using ORM repository directly
|
|
||||||
const ormBooking = await this.csvBookingRepository['repository'].findOne({
|
|
||||||
where: { id: bookingId },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (ormBooking) {
|
|
||||||
ormBooking.documents = updatedDocuments.map(doc => ({
|
|
||||||
id: doc.id,
|
|
||||||
type: doc.type,
|
|
||||||
fileName: doc.fileName,
|
|
||||||
filePath: doc.filePath,
|
|
||||||
mimeType: doc.mimeType,
|
|
||||||
size: doc.size,
|
|
||||||
uploadedAt: doc.uploadedAt,
|
|
||||||
}));
|
|
||||||
await this.csvBookingRepository['repository'].save(ormBooking);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Optionally delete from S3 (commented out for safety - keep files for audit)
|
|
||||||
// try {
|
|
||||||
// await this.storageAdapter.delete({
|
|
||||||
// bucket: 'xpeditis-documents',
|
|
||||||
// key: documentToDelete.filePath,
|
|
||||||
// });
|
|
||||||
// } catch (error) {
|
|
||||||
// this.logger.warn(`Failed to delete file from S3: ${documentToDelete.filePath}`);
|
|
||||||
// }
|
|
||||||
|
|
||||||
this.logger.log(`Deleted document ${documentId} from booking ${bookingId}`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: 'Document deleted successfully',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Replace a document in an existing booking
|
|
||||||
*/
|
|
||||||
async replaceDocument(
|
|
||||||
bookingId: string,
|
|
||||||
documentId: string,
|
|
||||||
file: Express.Multer.File,
|
|
||||||
userId: string
|
|
||||||
): Promise<{ success: boolean; message: string; newDocument: any }> {
|
|
||||||
this.logger.log(`Replacing document ${documentId} in booking ${bookingId}`);
|
|
||||||
|
|
||||||
const booking = await this.csvBookingRepository.findById(bookingId);
|
|
||||||
|
|
||||||
if (!booking) {
|
|
||||||
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify user owns this booking
|
|
||||||
if (booking.userId !== userId) {
|
|
||||||
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the document to replace
|
|
||||||
const documentIndex = booking.documents.findIndex(doc => doc.id === documentId);
|
|
||||||
|
|
||||||
if (documentIndex === -1) {
|
|
||||||
throw new NotFoundException(`Document with ID ${documentId} not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Upload the new document
|
|
||||||
const newDocuments = await this.uploadDocuments([file], bookingId);
|
|
||||||
const newDocument = newDocuments[0];
|
|
||||||
|
|
||||||
// Replace the document in the array
|
|
||||||
const updatedDocuments = [...booking.documents];
|
|
||||||
updatedDocuments[documentIndex] = newDocument;
|
|
||||||
|
|
||||||
// Update booking in database using ORM repository directly
|
|
||||||
const ormBooking = await this.csvBookingRepository['repository'].findOne({
|
|
||||||
where: { id: bookingId },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (ormBooking) {
|
|
||||||
ormBooking.documents = updatedDocuments.map(doc => ({
|
|
||||||
id: doc.id,
|
|
||||||
type: doc.type,
|
|
||||||
fileName: doc.fileName,
|
|
||||||
filePath: doc.filePath,
|
|
||||||
mimeType: doc.mimeType,
|
|
||||||
size: doc.size,
|
|
||||||
uploadedAt: doc.uploadedAt,
|
|
||||||
}));
|
|
||||||
await this.csvBookingRepository['repository'].save(ormBooking);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.log(`Replaced document ${documentId} with ${newDocument.id} in booking ${bookingId}`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: 'Document replaced successfully',
|
|
||||||
newDocument: {
|
|
||||||
id: newDocument.id,
|
|
||||||
type: newDocument.type,
|
|
||||||
fileName: newDocument.fileName,
|
|
||||||
filePath: newDocument.filePath,
|
|
||||||
mimeType: newDocument.mimeType,
|
|
||||||
size: newDocument.size,
|
|
||||||
uploadedAt: newDocument.uploadedAt,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Infer document type from filename
|
* Infer document type from filename
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -5,7 +5,6 @@ import {
|
|||||||
ConflictException,
|
ConflictException,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
ForbiddenException,
|
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import {
|
import {
|
||||||
@ -20,7 +19,6 @@ import {
|
|||||||
import { EmailPort, EMAIL_PORT } from '@domain/ports/out/email.port';
|
import { EmailPort, EMAIL_PORT } from '@domain/ports/out/email.port';
|
||||||
import { InvitationToken } from '@domain/entities/invitation-token.entity';
|
import { InvitationToken } from '@domain/entities/invitation-token.entity';
|
||||||
import { UserRole } from '@domain/entities/user.entity';
|
import { UserRole } from '@domain/entities/user.entity';
|
||||||
import { SubscriptionService } from './subscription.service';
|
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import * as crypto from 'crypto';
|
import * as crypto from 'crypto';
|
||||||
|
|
||||||
@ -37,8 +35,7 @@ export class InvitationService {
|
|||||||
private readonly organizationRepository: OrganizationRepository,
|
private readonly organizationRepository: OrganizationRepository,
|
||||||
@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,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -68,18 +65,6 @@ export class InvitationService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if licenses are available for this organization
|
|
||||||
const canInviteResult = await this.subscriptionService.canInviteUser(organizationId);
|
|
||||||
if (!canInviteResult.canInvite) {
|
|
||||||
this.logger.warn(
|
|
||||||
`License limit reached for organization ${organizationId}: ${canInviteResult.usedLicenses}/${canInviteResult.maxLicenses}`,
|
|
||||||
);
|
|
||||||
throw new ForbiddenException(
|
|
||||||
canInviteResult.message ||
|
|
||||||
`License limit reached. Please upgrade your subscription to invite more users.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate unique token
|
// Generate unique token
|
||||||
const token = this.generateToken();
|
const token = this.generateToken();
|
||||||
|
|
||||||
|
|||||||
@ -1,684 +0,0 @@
|
|||||||
/**
|
|
||||||
* Subscription Service
|
|
||||||
*
|
|
||||||
* Business logic for subscription and license management.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {
|
|
||||||
Injectable,
|
|
||||||
Inject,
|
|
||||||
Logger,
|
|
||||||
NotFoundException,
|
|
||||||
BadRequestException,
|
|
||||||
ForbiddenException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
|
||||||
import {
|
|
||||||
SubscriptionRepository,
|
|
||||||
SUBSCRIPTION_REPOSITORY,
|
|
||||||
} from '@domain/ports/out/subscription.repository';
|
|
||||||
import {
|
|
||||||
LicenseRepository,
|
|
||||||
LICENSE_REPOSITORY,
|
|
||||||
} from '@domain/ports/out/license.repository';
|
|
||||||
import {
|
|
||||||
OrganizationRepository,
|
|
||||||
ORGANIZATION_REPOSITORY,
|
|
||||||
} from '@domain/ports/out/organization.repository';
|
|
||||||
import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.repository';
|
|
||||||
import { StripePort, STRIPE_PORT } from '@domain/ports/out/stripe.port';
|
|
||||||
import { Subscription } from '@domain/entities/subscription.entity';
|
|
||||||
import { License } from '@domain/entities/license.entity';
|
|
||||||
import {
|
|
||||||
SubscriptionPlan,
|
|
||||||
SubscriptionPlanType,
|
|
||||||
} from '@domain/value-objects/subscription-plan.vo';
|
|
||||||
import { SubscriptionStatus } from '@domain/value-objects/subscription-status.vo';
|
|
||||||
import {
|
|
||||||
NoLicensesAvailableException,
|
|
||||||
SubscriptionNotFoundException,
|
|
||||||
LicenseAlreadyAssignedException,
|
|
||||||
} from '@domain/exceptions/subscription.exceptions';
|
|
||||||
import {
|
|
||||||
CreateCheckoutSessionDto,
|
|
||||||
CreatePortalSessionDto,
|
|
||||||
SubscriptionOverviewResponseDto,
|
|
||||||
CanInviteResponseDto,
|
|
||||||
CheckoutSessionResponseDto,
|
|
||||||
PortalSessionResponseDto,
|
|
||||||
LicenseResponseDto,
|
|
||||||
PlanDetailsDto,
|
|
||||||
AllPlansResponseDto,
|
|
||||||
SubscriptionPlanDto,
|
|
||||||
SubscriptionStatusDto,
|
|
||||||
} from '../dto/subscription.dto';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class SubscriptionService {
|
|
||||||
private readonly logger = new Logger(SubscriptionService.name);
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
@Inject(SUBSCRIPTION_REPOSITORY)
|
|
||||||
private readonly subscriptionRepository: SubscriptionRepository,
|
|
||||||
@Inject(LICENSE_REPOSITORY)
|
|
||||||
private readonly licenseRepository: LicenseRepository,
|
|
||||||
@Inject(ORGANIZATION_REPOSITORY)
|
|
||||||
private readonly organizationRepository: OrganizationRepository,
|
|
||||||
@Inject(USER_REPOSITORY)
|
|
||||||
private readonly userRepository: UserRepository,
|
|
||||||
@Inject(STRIPE_PORT)
|
|
||||||
private readonly stripeAdapter: StripePort,
|
|
||||||
private readonly configService: ConfigService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get subscription overview for an organization
|
|
||||||
*/
|
|
||||||
async getSubscriptionOverview(
|
|
||||||
organizationId: string,
|
|
||||||
): Promise<SubscriptionOverviewResponseDto> {
|
|
||||||
const subscription = await this.getOrCreateSubscription(organizationId);
|
|
||||||
const activeLicenses = await this.licenseRepository.findActiveBySubscriptionId(
|
|
||||||
subscription.id,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Enrich licenses with user information
|
|
||||||
const enrichedLicenses = await Promise.all(
|
|
||||||
activeLicenses.map(async (license) => {
|
|
||||||
const user = await this.userRepository.findById(license.userId);
|
|
||||||
return this.mapLicenseToDto(license, user);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Count only non-ADMIN licenses for quota calculation
|
|
||||||
// ADMIN users have unlimited licenses and don't count against the quota
|
|
||||||
const usedLicenses = await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins(
|
|
||||||
subscription.id,
|
|
||||||
);
|
|
||||||
const maxLicenses = subscription.maxLicenses;
|
|
||||||
const availableLicenses = subscription.isUnlimited()
|
|
||||||
? -1
|
|
||||||
: Math.max(0, maxLicenses - usedLicenses);
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: subscription.id,
|
|
||||||
organizationId: subscription.organizationId,
|
|
||||||
plan: subscription.plan.value as SubscriptionPlanDto,
|
|
||||||
planDetails: this.mapPlanToDto(subscription.plan),
|
|
||||||
status: subscription.status.value as SubscriptionStatusDto,
|
|
||||||
usedLicenses,
|
|
||||||
maxLicenses,
|
|
||||||
availableLicenses,
|
|
||||||
cancelAtPeriodEnd: subscription.cancelAtPeriodEnd,
|
|
||||||
currentPeriodStart: subscription.currentPeriodStart || undefined,
|
|
||||||
currentPeriodEnd: subscription.currentPeriodEnd || undefined,
|
|
||||||
createdAt: subscription.createdAt,
|
|
||||||
updatedAt: subscription.updatedAt,
|
|
||||||
licenses: enrichedLicenses,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all available plans
|
|
||||||
*/
|
|
||||||
getAllPlans(): AllPlansResponseDto {
|
|
||||||
const plans = SubscriptionPlan.getAllPlans().map((plan) =>
|
|
||||||
this.mapPlanToDto(plan),
|
|
||||||
);
|
|
||||||
return { plans };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if organization can invite more users
|
|
||||||
* Note: ADMIN users don't count against the license quota
|
|
||||||
*/
|
|
||||||
async canInviteUser(organizationId: string): Promise<CanInviteResponseDto> {
|
|
||||||
const subscription = await this.getOrCreateSubscription(organizationId);
|
|
||||||
// Count only non-ADMIN licenses - ADMIN users have unlimited licenses
|
|
||||||
const usedLicenses = await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins(
|
|
||||||
subscription.id,
|
|
||||||
);
|
|
||||||
|
|
||||||
const maxLicenses = subscription.maxLicenses;
|
|
||||||
const canInvite =
|
|
||||||
subscription.isActive() &&
|
|
||||||
(subscription.isUnlimited() || usedLicenses < maxLicenses);
|
|
||||||
|
|
||||||
const availableLicenses = subscription.isUnlimited()
|
|
||||||
? -1
|
|
||||||
: Math.max(0, maxLicenses - usedLicenses);
|
|
||||||
|
|
||||||
let message: string | undefined;
|
|
||||||
if (!subscription.isActive()) {
|
|
||||||
message = 'Your subscription is not active. Please update your payment method.';
|
|
||||||
} else if (!canInvite) {
|
|
||||||
message = `You have reached the maximum number of users (${maxLicenses}) for your ${subscription.plan.name} plan. Upgrade to add more users.`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
canInvite,
|
|
||||||
availableLicenses,
|
|
||||||
usedLicenses,
|
|
||||||
maxLicenses,
|
|
||||||
message,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a Stripe Checkout session for subscription upgrade
|
|
||||||
*/
|
|
||||||
async createCheckoutSession(
|
|
||||||
organizationId: string,
|
|
||||||
userId: string,
|
|
||||||
dto: CreateCheckoutSessionDto,
|
|
||||||
): Promise<CheckoutSessionResponseDto> {
|
|
||||||
const organization = await this.organizationRepository.findById(organizationId);
|
|
||||||
if (!organization) {
|
|
||||||
throw new NotFoundException('Organization not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = await this.userRepository.findById(userId);
|
|
||||||
if (!user) {
|
|
||||||
throw new NotFoundException('User not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cannot checkout for FREE plan
|
|
||||||
if (dto.plan === SubscriptionPlanDto.FREE) {
|
|
||||||
throw new BadRequestException('Cannot create checkout session for FREE plan');
|
|
||||||
}
|
|
||||||
|
|
||||||
const subscription = await this.getOrCreateSubscription(organizationId);
|
|
||||||
|
|
||||||
const frontendUrl = this.configService.get<string>(
|
|
||||||
'FRONTEND_URL',
|
|
||||||
'http://localhost:3000',
|
|
||||||
);
|
|
||||||
// Include {CHECKOUT_SESSION_ID} placeholder - Stripe replaces it with actual session ID
|
|
||||||
const successUrl =
|
|
||||||
dto.successUrl ||
|
|
||||||
`${frontendUrl}/dashboard/settings/organization?success=true&session_id={CHECKOUT_SESSION_ID}`;
|
|
||||||
const cancelUrl =
|
|
||||||
dto.cancelUrl ||
|
|
||||||
`${frontendUrl}/dashboard/settings/organization?canceled=true`;
|
|
||||||
|
|
||||||
const result = await this.stripeAdapter.createCheckoutSession({
|
|
||||||
organizationId,
|
|
||||||
organizationName: organization.name,
|
|
||||||
email: user.email,
|
|
||||||
plan: dto.plan as SubscriptionPlanType,
|
|
||||||
billingInterval: dto.billingInterval as 'monthly' | 'yearly',
|
|
||||||
successUrl,
|
|
||||||
cancelUrl,
|
|
||||||
customerId: subscription.stripeCustomerId || undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.logger.log(
|
|
||||||
`Created checkout session for organization ${organizationId}, plan ${dto.plan}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
sessionId: result.sessionId,
|
|
||||||
sessionUrl: result.sessionUrl,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a Stripe Customer Portal session
|
|
||||||
*/
|
|
||||||
async createPortalSession(
|
|
||||||
organizationId: string,
|
|
||||||
dto: CreatePortalSessionDto,
|
|
||||||
): Promise<PortalSessionResponseDto> {
|
|
||||||
const subscription = await this.subscriptionRepository.findByOrganizationId(
|
|
||||||
organizationId,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!subscription?.stripeCustomerId) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
'No Stripe customer found for this organization. Please complete a checkout first.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const frontendUrl = this.configService.get<string>(
|
|
||||||
'FRONTEND_URL',
|
|
||||||
'http://localhost:3000',
|
|
||||||
);
|
|
||||||
const returnUrl =
|
|
||||||
dto.returnUrl || `${frontendUrl}/dashboard/settings/organization`;
|
|
||||||
|
|
||||||
const result = await this.stripeAdapter.createPortalSession({
|
|
||||||
customerId: subscription.stripeCustomerId,
|
|
||||||
returnUrl,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.logger.log(`Created portal session for organization ${organizationId}`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
sessionUrl: result.sessionUrl,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sync subscription from Stripe
|
|
||||||
* Useful when webhooks are not available (e.g., local development)
|
|
||||||
* @param organizationId - The organization ID
|
|
||||||
* @param sessionId - Optional Stripe checkout session ID (used after checkout completes)
|
|
||||||
*/
|
|
||||||
async syncFromStripe(
|
|
||||||
organizationId: string,
|
|
||||||
sessionId?: string,
|
|
||||||
): Promise<SubscriptionOverviewResponseDto> {
|
|
||||||
let subscription = await this.subscriptionRepository.findByOrganizationId(
|
|
||||||
organizationId,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!subscription) {
|
|
||||||
subscription = await this.getOrCreateSubscription(organizationId);
|
|
||||||
}
|
|
||||||
|
|
||||||
let stripeSubscriptionId = subscription.stripeSubscriptionId;
|
|
||||||
let stripeCustomerId = subscription.stripeCustomerId;
|
|
||||||
|
|
||||||
// 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
|
|
||||||
if (sessionId) {
|
|
||||||
this.logger.log(`Retrieving checkout session ${sessionId} for organization ${organizationId}`);
|
|
||||||
const checkoutSession = await this.stripeAdapter.getCheckoutSession(sessionId);
|
|
||||||
|
|
||||||
if (checkoutSession) {
|
|
||||||
this.logger.log(
|
|
||||||
`Checkout session found: subscriptionId=${checkoutSession.subscriptionId}, customerId=${checkoutSession.customerId}, status=${checkoutSession.status}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Always use the subscription ID from the checkout session if available
|
|
||||||
// This handles upgrades where a new subscription is created
|
|
||||||
if (checkoutSession.subscriptionId) {
|
|
||||||
stripeSubscriptionId = checkoutSession.subscriptionId;
|
|
||||||
}
|
|
||||||
if (checkoutSession.customerId) {
|
|
||||||
stripeCustomerId = checkoutSession.customerId;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update subscription with customer ID if we got it from checkout session
|
|
||||||
if (stripeCustomerId && !subscription.stripeCustomerId) {
|
|
||||||
subscription = subscription.updateStripeCustomerId(stripeCustomerId);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
this.logger.warn(`Checkout session ${sessionId} not found`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!stripeSubscriptionId) {
|
|
||||||
this.logger.log(`No Stripe subscription found for organization ${organizationId}`);
|
|
||||||
// Return current subscription data without syncing
|
|
||||||
return this.getSubscriptionOverview(organizationId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get fresh data from Stripe
|
|
||||||
const stripeData = await this.stripeAdapter.getSubscription(stripeSubscriptionId);
|
|
||||||
|
|
||||||
if (!stripeData) {
|
|
||||||
this.logger.warn(`Could not retrieve Stripe subscription ${stripeSubscriptionId}`);
|
|
||||||
return this.getSubscriptionOverview(organizationId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Map the price ID to our plan
|
|
||||||
const plan = this.stripeAdapter.mapPriceIdToPlan(stripeData.planId);
|
|
||||||
let updatedSubscription = subscription;
|
|
||||||
|
|
||||||
if (plan) {
|
|
||||||
// Count only non-ADMIN licenses - ADMIN users have unlimited licenses
|
|
||||||
const usedLicenses = await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins(
|
|
||||||
subscription.id,
|
|
||||||
);
|
|
||||||
const newPlan = SubscriptionPlan.create(plan);
|
|
||||||
|
|
||||||
// Update plan
|
|
||||||
updatedSubscription = updatedSubscription.updatePlan(newPlan, usedLicenses);
|
|
||||||
this.logger.log(`Updated plan to ${plan} for organization ${organizationId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update Stripe IDs if not already set
|
|
||||||
if (!updatedSubscription.stripeCustomerId && stripeData.customerId) {
|
|
||||||
updatedSubscription = updatedSubscription.updateStripeCustomerId(stripeData.customerId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update Stripe subscription data
|
|
||||||
updatedSubscription = updatedSubscription.updateStripeSubscription({
|
|
||||||
stripeSubscriptionId: stripeData.subscriptionId,
|
|
||||||
currentPeriodStart: stripeData.currentPeriodStart,
|
|
||||||
currentPeriodEnd: stripeData.currentPeriodEnd,
|
|
||||||
cancelAtPeriodEnd: stripeData.cancelAtPeriodEnd,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update status
|
|
||||||
updatedSubscription = updatedSubscription.updateStatus(
|
|
||||||
SubscriptionStatus.fromStripeStatus(stripeData.status),
|
|
||||||
);
|
|
||||||
|
|
||||||
await this.subscriptionRepository.save(updatedSubscription);
|
|
||||||
|
|
||||||
this.logger.log(
|
|
||||||
`Synced subscription for organization ${organizationId} from Stripe (plan: ${updatedSubscription.plan.value})`,
|
|
||||||
);
|
|
||||||
|
|
||||||
return this.getSubscriptionOverview(organizationId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle Stripe webhook events
|
|
||||||
*/
|
|
||||||
async handleStripeWebhook(payload: string | Buffer, signature: string): Promise<void> {
|
|
||||||
const event = await this.stripeAdapter.constructWebhookEvent(payload, signature);
|
|
||||||
|
|
||||||
this.logger.log(`Processing Stripe webhook event: ${event.type}`);
|
|
||||||
|
|
||||||
switch (event.type) {
|
|
||||||
case 'checkout.session.completed':
|
|
||||||
await this.handleCheckoutCompleted(event.data.object);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'customer.subscription.created':
|
|
||||||
case 'customer.subscription.updated':
|
|
||||||
await this.handleSubscriptionUpdated(event.data.object);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'customer.subscription.deleted':
|
|
||||||
await this.handleSubscriptionDeleted(event.data.object);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'invoice.payment_failed':
|
|
||||||
await this.handlePaymentFailed(event.data.object);
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
this.logger.log(`Unhandled Stripe event type: ${event.type}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Allocate a license to a user
|
|
||||||
* Note: ADMIN users always get a license (unlimited) and don't count against the quota
|
|
||||||
*/
|
|
||||||
async allocateLicense(userId: string, organizationId: string): Promise<License> {
|
|
||||||
const subscription = await this.getOrCreateSubscription(organizationId);
|
|
||||||
|
|
||||||
// Check if user already has a license
|
|
||||||
const existingLicense = await this.licenseRepository.findByUserId(userId);
|
|
||||||
if (existingLicense?.isActive()) {
|
|
||||||
throw new LicenseAlreadyAssignedException(userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the user to check if they're an ADMIN
|
|
||||||
const user = await this.userRepository.findById(userId);
|
|
||||||
const isAdmin = user?.role === 'ADMIN';
|
|
||||||
|
|
||||||
// ADMIN users have unlimited licenses - skip quota check for them
|
|
||||||
if (!isAdmin) {
|
|
||||||
// Count only non-ADMIN licenses for quota check
|
|
||||||
const usedLicenses = await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins(
|
|
||||||
subscription.id,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!subscription.canAllocateLicenses(usedLicenses)) {
|
|
||||||
throw new NoLicensesAvailableException(
|
|
||||||
organizationId,
|
|
||||||
usedLicenses,
|
|
||||||
subscription.maxLicenses,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If there's a revoked license, reactivate it
|
|
||||||
if (existingLicense?.isRevoked()) {
|
|
||||||
const reactivatedLicense = existingLicense.reactivate();
|
|
||||||
return this.licenseRepository.save(reactivatedLicense);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create new license
|
|
||||||
const license = License.create({
|
|
||||||
id: uuidv4(),
|
|
||||||
subscriptionId: subscription.id,
|
|
||||||
userId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const savedLicense = await this.licenseRepository.save(license);
|
|
||||||
this.logger.log(`Allocated license ${savedLicense.id} to user ${userId} (isAdmin: ${isAdmin})`);
|
|
||||||
|
|
||||||
return savedLicense;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Revoke a user's license
|
|
||||||
*/
|
|
||||||
async revokeLicense(userId: string): Promise<void> {
|
|
||||||
const license = await this.licenseRepository.findByUserId(userId);
|
|
||||||
if (!license) {
|
|
||||||
this.logger.warn(`No license found for user ${userId}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (license.isRevoked()) {
|
|
||||||
this.logger.warn(`License for user ${userId} is already revoked`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const revokedLicense = license.revoke();
|
|
||||||
await this.licenseRepository.save(revokedLicense);
|
|
||||||
|
|
||||||
this.logger.log(`Revoked license ${license.id} for user ${userId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get or create a subscription for an organization
|
|
||||||
*/
|
|
||||||
async getOrCreateSubscription(organizationId: string): Promise<Subscription> {
|
|
||||||
let subscription = await this.subscriptionRepository.findByOrganizationId(
|
|
||||||
organizationId,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!subscription) {
|
|
||||||
// Create FREE subscription for the organization
|
|
||||||
subscription = Subscription.create({
|
|
||||||
id: uuidv4(),
|
|
||||||
organizationId,
|
|
||||||
plan: SubscriptionPlan.free(),
|
|
||||||
});
|
|
||||||
|
|
||||||
subscription = await this.subscriptionRepository.save(subscription);
|
|
||||||
this.logger.log(
|
|
||||||
`Created FREE subscription for organization ${organizationId}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return subscription;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Private helper methods
|
|
||||||
|
|
||||||
private async handleCheckoutCompleted(
|
|
||||||
session: Record<string, unknown>,
|
|
||||||
): Promise<void> {
|
|
||||||
const metadata = session.metadata as Record<string, string> | undefined;
|
|
||||||
const organizationId = metadata?.organizationId;
|
|
||||||
const customerId = session.customer as string;
|
|
||||||
const subscriptionId = session.subscription as string;
|
|
||||||
|
|
||||||
if (!organizationId || !customerId || !subscriptionId) {
|
|
||||||
this.logger.warn('Checkout session missing required metadata');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get subscription details from Stripe
|
|
||||||
const stripeSubscription = await this.stripeAdapter.getSubscription(subscriptionId);
|
|
||||||
if (!stripeSubscription) {
|
|
||||||
this.logger.error(`Could not retrieve Stripe subscription ${subscriptionId}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get or create our subscription
|
|
||||||
let subscription = await this.getOrCreateSubscription(organizationId);
|
|
||||||
|
|
||||||
// Map the price ID to our plan
|
|
||||||
const plan = this.stripeAdapter.mapPriceIdToPlan(stripeSubscription.planId);
|
|
||||||
if (!plan) {
|
|
||||||
this.logger.error(`Unknown Stripe price ID: ${stripeSubscription.planId}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update subscription
|
|
||||||
subscription = subscription.updateStripeCustomerId(customerId);
|
|
||||||
subscription = subscription.updateStripeSubscription({
|
|
||||||
stripeSubscriptionId: subscriptionId,
|
|
||||||
currentPeriodStart: stripeSubscription.currentPeriodStart,
|
|
||||||
currentPeriodEnd: stripeSubscription.currentPeriodEnd,
|
|
||||||
cancelAtPeriodEnd: stripeSubscription.cancelAtPeriodEnd,
|
|
||||||
});
|
|
||||||
subscription = subscription.updatePlan(
|
|
||||||
SubscriptionPlan.create(plan),
|
|
||||||
await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins(subscription.id),
|
|
||||||
);
|
|
||||||
subscription = subscription.updateStatus(
|
|
||||||
SubscriptionStatus.fromStripeStatus(stripeSubscription.status),
|
|
||||||
);
|
|
||||||
|
|
||||||
await this.subscriptionRepository.save(subscription);
|
|
||||||
|
|
||||||
this.logger.log(
|
|
||||||
`Updated subscription for organization ${organizationId} to plan ${plan}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleSubscriptionUpdated(
|
|
||||||
stripeSubscription: Record<string, unknown>,
|
|
||||||
): Promise<void> {
|
|
||||||
const subscriptionId = stripeSubscription.id as string;
|
|
||||||
|
|
||||||
let subscription = await this.subscriptionRepository.findByStripeSubscriptionId(
|
|
||||||
subscriptionId,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!subscription) {
|
|
||||||
this.logger.warn(`Subscription ${subscriptionId} not found in database`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get fresh data from Stripe
|
|
||||||
const stripeData = await this.stripeAdapter.getSubscription(subscriptionId);
|
|
||||||
if (!stripeData) {
|
|
||||||
this.logger.error(`Could not retrieve Stripe subscription ${subscriptionId}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Map the price ID to our plan
|
|
||||||
const plan = this.stripeAdapter.mapPriceIdToPlan(stripeData.planId);
|
|
||||||
if (plan) {
|
|
||||||
// Count only non-ADMIN licenses - ADMIN users have unlimited licenses
|
|
||||||
const usedLicenses = await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins(
|
|
||||||
subscription.id,
|
|
||||||
);
|
|
||||||
const newPlan = SubscriptionPlan.create(plan);
|
|
||||||
|
|
||||||
// Only update plan if it can accommodate current non-ADMIN users
|
|
||||||
if (newPlan.canAccommodateUsers(usedLicenses)) {
|
|
||||||
subscription = subscription.updatePlan(newPlan, usedLicenses);
|
|
||||||
} else {
|
|
||||||
this.logger.warn(
|
|
||||||
`Cannot update to plan ${plan} - would exceed license limit`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
subscription = subscription.updateStripeSubscription({
|
|
||||||
stripeSubscriptionId: subscriptionId,
|
|
||||||
currentPeriodStart: stripeData.currentPeriodStart,
|
|
||||||
currentPeriodEnd: stripeData.currentPeriodEnd,
|
|
||||||
cancelAtPeriodEnd: stripeData.cancelAtPeriodEnd,
|
|
||||||
});
|
|
||||||
subscription = subscription.updateStatus(
|
|
||||||
SubscriptionStatus.fromStripeStatus(stripeData.status),
|
|
||||||
);
|
|
||||||
|
|
||||||
await this.subscriptionRepository.save(subscription);
|
|
||||||
|
|
||||||
this.logger.log(`Updated subscription ${subscriptionId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleSubscriptionDeleted(
|
|
||||||
stripeSubscription: Record<string, unknown>,
|
|
||||||
): Promise<void> {
|
|
||||||
const subscriptionId = stripeSubscription.id as string;
|
|
||||||
|
|
||||||
const subscription = await this.subscriptionRepository.findByStripeSubscriptionId(
|
|
||||||
subscriptionId,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!subscription) {
|
|
||||||
this.logger.warn(`Subscription ${subscriptionId} not found in database`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Downgrade to FREE plan - count only non-ADMIN licenses
|
|
||||||
const canceledSubscription = subscription
|
|
||||||
.updatePlan(
|
|
||||||
SubscriptionPlan.free(),
|
|
||||||
await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins(subscription.id),
|
|
||||||
)
|
|
||||||
.updateStatus(SubscriptionStatus.canceled());
|
|
||||||
|
|
||||||
await this.subscriptionRepository.save(canceledSubscription);
|
|
||||||
|
|
||||||
this.logger.log(`Subscription ${subscriptionId} canceled, downgraded to FREE`);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handlePaymentFailed(invoice: Record<string, unknown>): Promise<void> {
|
|
||||||
const customerId = invoice.customer as string;
|
|
||||||
|
|
||||||
const subscription = await this.subscriptionRepository.findByStripeCustomerId(
|
|
||||||
customerId,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!subscription) {
|
|
||||||
this.logger.warn(`Subscription for customer ${customerId} not found`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedSubscription = subscription.updateStatus(
|
|
||||||
SubscriptionStatus.pastDue(),
|
|
||||||
);
|
|
||||||
|
|
||||||
await this.subscriptionRepository.save(updatedSubscription);
|
|
||||||
|
|
||||||
this.logger.log(
|
|
||||||
`Subscription ${subscription.id} marked as past due due to payment failure`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private mapLicenseToDto(
|
|
||||||
license: License,
|
|
||||||
user: { email: string; firstName: string; lastName: string; role: string } | null,
|
|
||||||
): LicenseResponseDto {
|
|
||||||
return {
|
|
||||||
id: license.id,
|
|
||||||
userId: license.userId,
|
|
||||||
userEmail: user?.email || 'Unknown',
|
|
||||||
userName: user ? `${user.firstName} ${user.lastName}` : 'Unknown User',
|
|
||||||
userRole: user?.role || 'USER',
|
|
||||||
status: license.status.value,
|
|
||||||
assignedAt: license.assignedAt,
|
|
||||||
revokedAt: license.revokedAt || undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private mapPlanToDto(plan: SubscriptionPlan): PlanDetailsDto {
|
|
||||||
return {
|
|
||||||
plan: plan.value as SubscriptionPlanDto,
|
|
||||||
name: plan.name,
|
|
||||||
maxLicenses: plan.maxLicenses,
|
|
||||||
monthlyPriceEur: plan.monthlyPriceEur,
|
|
||||||
yearlyPriceEur: plan.yearlyPriceEur,
|
|
||||||
features: [...plan.features],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,71 +0,0 @@
|
|||||||
/**
|
|
||||||
* Subscriptions Module
|
|
||||||
*
|
|
||||||
* Provides subscription and license management endpoints.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Module } from '@nestjs/common';
|
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
|
||||||
import { ConfigModule } from '@nestjs/config';
|
|
||||||
|
|
||||||
// Controller
|
|
||||||
import { SubscriptionsController } from '../controllers/subscriptions.controller';
|
|
||||||
|
|
||||||
// Service
|
|
||||||
import { SubscriptionService } from '../services/subscription.service';
|
|
||||||
|
|
||||||
// ORM Entities
|
|
||||||
import { SubscriptionOrmEntity } from '@infrastructure/persistence/typeorm/entities/subscription.orm-entity';
|
|
||||||
import { LicenseOrmEntity } from '@infrastructure/persistence/typeorm/entities/license.orm-entity';
|
|
||||||
import { OrganizationOrmEntity } from '@infrastructure/persistence/typeorm/entities/organization.orm-entity';
|
|
||||||
import { UserOrmEntity } from '@infrastructure/persistence/typeorm/entities/user.orm-entity';
|
|
||||||
|
|
||||||
// Repositories
|
|
||||||
import { TypeOrmSubscriptionRepository } from '@infrastructure/persistence/typeorm/repositories/typeorm-subscription.repository';
|
|
||||||
import { TypeOrmLicenseRepository } from '@infrastructure/persistence/typeorm/repositories/typeorm-license.repository';
|
|
||||||
import { TypeOrmOrganizationRepository } from '@infrastructure/persistence/typeorm/repositories/typeorm-organization.repository';
|
|
||||||
import { TypeOrmUserRepository } from '@infrastructure/persistence/typeorm/repositories/typeorm-user.repository';
|
|
||||||
|
|
||||||
// Repository tokens
|
|
||||||
import { SUBSCRIPTION_REPOSITORY } from '@domain/ports/out/subscription.repository';
|
|
||||||
import { LICENSE_REPOSITORY } from '@domain/ports/out/license.repository';
|
|
||||||
import { ORGANIZATION_REPOSITORY } from '@domain/ports/out/organization.repository';
|
|
||||||
import { USER_REPOSITORY } from '@domain/ports/out/user.repository';
|
|
||||||
|
|
||||||
// Stripe
|
|
||||||
import { StripeModule } from '@infrastructure/stripe/stripe.module';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
imports: [
|
|
||||||
ConfigModule,
|
|
||||||
TypeOrmModule.forFeature([
|
|
||||||
SubscriptionOrmEntity,
|
|
||||||
LicenseOrmEntity,
|
|
||||||
OrganizationOrmEntity,
|
|
||||||
UserOrmEntity,
|
|
||||||
]),
|
|
||||||
StripeModule,
|
|
||||||
],
|
|
||||||
controllers: [SubscriptionsController],
|
|
||||||
providers: [
|
|
||||||
SubscriptionService,
|
|
||||||
{
|
|
||||||
provide: SUBSCRIPTION_REPOSITORY,
|
|
||||||
useClass: TypeOrmSubscriptionRepository,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: LICENSE_REPOSITORY,
|
|
||||||
useClass: TypeOrmLicenseRepository,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: ORGANIZATION_REPOSITORY,
|
|
||||||
useClass: TypeOrmOrganizationRepository,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: USER_REPOSITORY,
|
|
||||||
useClass: TypeOrmUserRepository,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
exports: [SubscriptionService, SUBSCRIPTION_REPOSITORY, LICENSE_REPOSITORY],
|
|
||||||
})
|
|
||||||
export class SubscriptionsModule {}
|
|
||||||
@ -6,12 +6,10 @@ import { UsersController } from '../controllers/users.controller';
|
|||||||
import { USER_REPOSITORY } from '@domain/ports/out/user.repository';
|
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';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([UserOrmEntity]),
|
TypeOrmModule.forFeature([UserOrmEntity]), // 👈 Add this line
|
||||||
SubscriptionsModule,
|
|
||||||
],
|
],
|
||||||
controllers: [UsersController],
|
controllers: [UsersController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@ -332,64 +332,4 @@ export class CsvBooking {
|
|||||||
toString(): string {
|
toString(): string {
|
||||||
return this.getSummary();
|
return this.getSummary();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a CsvBooking from persisted data (skips document validation)
|
|
||||||
*
|
|
||||||
* Use this when loading from database where bookings might have been created
|
|
||||||
* before document requirement was enforced, or documents were lost.
|
|
||||||
*/
|
|
||||||
static fromPersistence(
|
|
||||||
id: string,
|
|
||||||
userId: string,
|
|
||||||
organizationId: string,
|
|
||||||
carrierName: string,
|
|
||||||
carrierEmail: string,
|
|
||||||
origin: PortCode,
|
|
||||||
destination: PortCode,
|
|
||||||
volumeCBM: number,
|
|
||||||
weightKG: number,
|
|
||||||
palletCount: number,
|
|
||||||
priceUSD: number,
|
|
||||||
priceEUR: number,
|
|
||||||
primaryCurrency: string,
|
|
||||||
transitDays: number,
|
|
||||||
containerType: string,
|
|
||||||
status: CsvBookingStatus,
|
|
||||||
documents: CsvBookingDocument[],
|
|
||||||
confirmationToken: string,
|
|
||||||
requestedAt: Date,
|
|
||||||
respondedAt?: Date,
|
|
||||||
notes?: string,
|
|
||||||
rejectionReason?: string
|
|
||||||
): CsvBooking {
|
|
||||||
// Create instance without calling constructor validation
|
|
||||||
const booking = Object.create(CsvBooking.prototype);
|
|
||||||
|
|
||||||
// Assign all properties directly
|
|
||||||
booking.id = id;
|
|
||||||
booking.userId = userId;
|
|
||||||
booking.organizationId = organizationId;
|
|
||||||
booking.carrierName = carrierName;
|
|
||||||
booking.carrierEmail = carrierEmail;
|
|
||||||
booking.origin = origin;
|
|
||||||
booking.destination = destination;
|
|
||||||
booking.volumeCBM = volumeCBM;
|
|
||||||
booking.weightKG = weightKG;
|
|
||||||
booking.palletCount = palletCount;
|
|
||||||
booking.priceUSD = priceUSD;
|
|
||||||
booking.priceEUR = priceEUR;
|
|
||||||
booking.primaryCurrency = primaryCurrency;
|
|
||||||
booking.transitDays = transitDays;
|
|
||||||
booking.containerType = containerType;
|
|
||||||
booking.status = status;
|
|
||||||
booking.documents = documents || [];
|
|
||||||
booking.confirmationToken = confirmationToken;
|
|
||||||
booking.requestedAt = requestedAt;
|
|
||||||
booking.respondedAt = respondedAt;
|
|
||||||
booking.notes = notes;
|
|
||||||
booking.rejectionReason = rejectionReason;
|
|
||||||
|
|
||||||
return booking;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,5 +11,3 @@ export * from './port.entity';
|
|||||||
export * from './rate-quote.entity';
|
export * from './rate-quote.entity';
|
||||||
export * from './container.entity';
|
export * from './container.entity';
|
||||||
export * from './booking.entity';
|
export * from './booking.entity';
|
||||||
export * from './subscription.entity';
|
|
||||||
export * from './license.entity';
|
|
||||||
|
|||||||
@ -1,270 +0,0 @@
|
|||||||
/**
|
|
||||||
* License Entity Tests
|
|
||||||
*
|
|
||||||
* Unit tests for the License domain entity
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { License } from './license.entity';
|
|
||||||
|
|
||||||
describe('License Entity', () => {
|
|
||||||
const createValidLicense = () => {
|
|
||||||
return License.create({
|
|
||||||
id: 'license-123',
|
|
||||||
subscriptionId: 'sub-123',
|
|
||||||
userId: 'user-123',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('create', () => {
|
|
||||||
it('should create a license with valid data', () => {
|
|
||||||
const license = createValidLicense();
|
|
||||||
|
|
||||||
expect(license.id).toBe('license-123');
|
|
||||||
expect(license.subscriptionId).toBe('sub-123');
|
|
||||||
expect(license.userId).toBe('user-123');
|
|
||||||
expect(license.status.value).toBe('ACTIVE');
|
|
||||||
expect(license.assignedAt).toBeInstanceOf(Date);
|
|
||||||
expect(license.revokedAt).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create a license with different user', () => {
|
|
||||||
const license = License.create({
|
|
||||||
id: 'license-456',
|
|
||||||
subscriptionId: 'sub-123',
|
|
||||||
userId: 'user-456',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(license.userId).toBe('user-456');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('fromPersistence', () => {
|
|
||||||
it('should reconstitute an active license from persistence data', () => {
|
|
||||||
const assignedAt = new Date('2024-01-15');
|
|
||||||
const license = License.fromPersistence({
|
|
||||||
id: 'license-123',
|
|
||||||
subscriptionId: 'sub-123',
|
|
||||||
userId: 'user-123',
|
|
||||||
status: 'ACTIVE',
|
|
||||||
assignedAt,
|
|
||||||
revokedAt: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(license.id).toBe('license-123');
|
|
||||||
expect(license.status.value).toBe('ACTIVE');
|
|
||||||
expect(license.assignedAt).toEqual(assignedAt);
|
|
||||||
expect(license.revokedAt).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reconstitute a revoked license from persistence data', () => {
|
|
||||||
const revokedAt = new Date('2024-02-01');
|
|
||||||
const license = License.fromPersistence({
|
|
||||||
id: 'license-123',
|
|
||||||
subscriptionId: 'sub-123',
|
|
||||||
userId: 'user-123',
|
|
||||||
status: 'REVOKED',
|
|
||||||
assignedAt: new Date('2024-01-15'),
|
|
||||||
revokedAt,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(license.status.value).toBe('REVOKED');
|
|
||||||
expect(license.revokedAt).toEqual(revokedAt);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('isActive', () => {
|
|
||||||
it('should return true for active license', () => {
|
|
||||||
const license = createValidLicense();
|
|
||||||
expect(license.isActive()).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false for revoked license', () => {
|
|
||||||
const license = License.fromPersistence({
|
|
||||||
id: 'license-123',
|
|
||||||
subscriptionId: 'sub-123',
|
|
||||||
userId: 'user-123',
|
|
||||||
status: 'REVOKED',
|
|
||||||
assignedAt: new Date('2024-01-15'),
|
|
||||||
revokedAt: new Date('2024-02-01'),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(license.isActive()).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('isRevoked', () => {
|
|
||||||
it('should return false for active license', () => {
|
|
||||||
const license = createValidLicense();
|
|
||||||
expect(license.isRevoked()).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return true for revoked license', () => {
|
|
||||||
const license = License.fromPersistence({
|
|
||||||
id: 'license-123',
|
|
||||||
subscriptionId: 'sub-123',
|
|
||||||
userId: 'user-123',
|
|
||||||
status: 'REVOKED',
|
|
||||||
assignedAt: new Date('2024-01-15'),
|
|
||||||
revokedAt: new Date('2024-02-01'),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(license.isRevoked()).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('revoke', () => {
|
|
||||||
it('should revoke an active license', () => {
|
|
||||||
const license = createValidLicense();
|
|
||||||
const revoked = license.revoke();
|
|
||||||
|
|
||||||
expect(revoked.status.value).toBe('REVOKED');
|
|
||||||
expect(revoked.revokedAt).toBeInstanceOf(Date);
|
|
||||||
expect(revoked.isActive()).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw when revoking an already revoked license', () => {
|
|
||||||
const license = License.fromPersistence({
|
|
||||||
id: 'license-123',
|
|
||||||
subscriptionId: 'sub-123',
|
|
||||||
userId: 'user-123',
|
|
||||||
status: 'REVOKED',
|
|
||||||
assignedAt: new Date('2024-01-15'),
|
|
||||||
revokedAt: new Date('2024-02-01'),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(() => license.revoke()).toThrow('License is already revoked');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should preserve original data when revoking', () => {
|
|
||||||
const license = License.fromPersistence({
|
|
||||||
id: 'license-123',
|
|
||||||
subscriptionId: 'sub-456',
|
|
||||||
userId: 'user-789',
|
|
||||||
status: 'ACTIVE',
|
|
||||||
assignedAt: new Date('2024-01-15'),
|
|
||||||
revokedAt: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
const revoked = license.revoke();
|
|
||||||
|
|
||||||
expect(revoked.id).toBe('license-123');
|
|
||||||
expect(revoked.subscriptionId).toBe('sub-456');
|
|
||||||
expect(revoked.userId).toBe('user-789');
|
|
||||||
expect(revoked.assignedAt).toEqual(new Date('2024-01-15'));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('reactivate', () => {
|
|
||||||
it('should reactivate a revoked license', () => {
|
|
||||||
const license = License.fromPersistence({
|
|
||||||
id: 'license-123',
|
|
||||||
subscriptionId: 'sub-123',
|
|
||||||
userId: 'user-123',
|
|
||||||
status: 'REVOKED',
|
|
||||||
assignedAt: new Date('2024-01-15'),
|
|
||||||
revokedAt: new Date('2024-02-01'),
|
|
||||||
});
|
|
||||||
|
|
||||||
const reactivated = license.reactivate();
|
|
||||||
|
|
||||||
expect(reactivated.status.value).toBe('ACTIVE');
|
|
||||||
expect(reactivated.revokedAt).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw when reactivating an active license', () => {
|
|
||||||
const license = createValidLicense();
|
|
||||||
|
|
||||||
expect(() => license.reactivate()).toThrow('License is already active');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getActiveDuration', () => {
|
|
||||||
it('should calculate duration for active license', () => {
|
|
||||||
const assignedAt = new Date();
|
|
||||||
assignedAt.setHours(assignedAt.getHours() - 1); // 1 hour ago
|
|
||||||
|
|
||||||
const license = License.fromPersistence({
|
|
||||||
id: 'license-123',
|
|
||||||
subscriptionId: 'sub-123',
|
|
||||||
userId: 'user-123',
|
|
||||||
status: 'ACTIVE',
|
|
||||||
assignedAt,
|
|
||||||
revokedAt: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
const duration = license.getActiveDuration();
|
|
||||||
// Should be approximately 1 hour in milliseconds (allow some variance)
|
|
||||||
expect(duration).toBeGreaterThan(3600000 - 1000);
|
|
||||||
expect(duration).toBeLessThan(3600000 + 1000);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should calculate duration for revoked license', () => {
|
|
||||||
const assignedAt = new Date('2024-01-15T10:00:00Z');
|
|
||||||
const revokedAt = new Date('2024-01-15T12:00:00Z'); // 2 hours later
|
|
||||||
|
|
||||||
const license = License.fromPersistence({
|
|
||||||
id: 'license-123',
|
|
||||||
subscriptionId: 'sub-123',
|
|
||||||
userId: 'user-123',
|
|
||||||
status: 'REVOKED',
|
|
||||||
assignedAt,
|
|
||||||
revokedAt,
|
|
||||||
});
|
|
||||||
|
|
||||||
const duration = license.getActiveDuration();
|
|
||||||
expect(duration).toBe(2 * 60 * 60 * 1000); // 2 hours in ms
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('toObject', () => {
|
|
||||||
it('should convert to plain object for persistence', () => {
|
|
||||||
const license = createValidLicense();
|
|
||||||
const obj = license.toObject();
|
|
||||||
|
|
||||||
expect(obj.id).toBe('license-123');
|
|
||||||
expect(obj.subscriptionId).toBe('sub-123');
|
|
||||||
expect(obj.userId).toBe('user-123');
|
|
||||||
expect(obj.status).toBe('ACTIVE');
|
|
||||||
expect(obj.assignedAt).toBeInstanceOf(Date);
|
|
||||||
expect(obj.revokedAt).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should include revokedAt for revoked license', () => {
|
|
||||||
const revokedAt = new Date('2024-02-01');
|
|
||||||
const license = License.fromPersistence({
|
|
||||||
id: 'license-123',
|
|
||||||
subscriptionId: 'sub-123',
|
|
||||||
userId: 'user-123',
|
|
||||||
status: 'REVOKED',
|
|
||||||
assignedAt: new Date('2024-01-15'),
|
|
||||||
revokedAt,
|
|
||||||
});
|
|
||||||
|
|
||||||
const obj = license.toObject();
|
|
||||||
expect(obj.status).toBe('REVOKED');
|
|
||||||
expect(obj.revokedAt).toEqual(revokedAt);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('property accessors', () => {
|
|
||||||
it('should correctly expose all properties', () => {
|
|
||||||
const assignedAt = new Date('2024-01-15');
|
|
||||||
|
|
||||||
const license = License.fromPersistence({
|
|
||||||
id: 'license-123',
|
|
||||||
subscriptionId: 'sub-456',
|
|
||||||
userId: 'user-789',
|
|
||||||
status: 'ACTIVE',
|
|
||||||
assignedAt,
|
|
||||||
revokedAt: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(license.id).toBe('license-123');
|
|
||||||
expect(license.subscriptionId).toBe('sub-456');
|
|
||||||
expect(license.userId).toBe('user-789');
|
|
||||||
expect(license.status.value).toBe('ACTIVE');
|
|
||||||
expect(license.assignedAt).toEqual(assignedAt);
|
|
||||||
expect(license.revokedAt).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,167 +0,0 @@
|
|||||||
/**
|
|
||||||
* License Entity
|
|
||||||
*
|
|
||||||
* Represents a user license within a subscription.
|
|
||||||
* Each active user in an organization consumes one license.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {
|
|
||||||
LicenseStatus,
|
|
||||||
LicenseStatusType,
|
|
||||||
} from '../value-objects/license-status.vo';
|
|
||||||
|
|
||||||
export interface LicenseProps {
|
|
||||||
readonly id: string;
|
|
||||||
readonly subscriptionId: string;
|
|
||||||
readonly userId: string;
|
|
||||||
readonly status: LicenseStatus;
|
|
||||||
readonly assignedAt: Date;
|
|
||||||
readonly revokedAt: Date | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class License {
|
|
||||||
private readonly props: LicenseProps;
|
|
||||||
|
|
||||||
private constructor(props: LicenseProps) {
|
|
||||||
this.props = props;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new license for a user
|
|
||||||
*/
|
|
||||||
static create(props: {
|
|
||||||
id: string;
|
|
||||||
subscriptionId: string;
|
|
||||||
userId: string;
|
|
||||||
}): License {
|
|
||||||
return new License({
|
|
||||||
id: props.id,
|
|
||||||
subscriptionId: props.subscriptionId,
|
|
||||||
userId: props.userId,
|
|
||||||
status: LicenseStatus.active(),
|
|
||||||
assignedAt: new Date(),
|
|
||||||
revokedAt: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reconstitute from persistence
|
|
||||||
*/
|
|
||||||
static fromPersistence(props: {
|
|
||||||
id: string;
|
|
||||||
subscriptionId: string;
|
|
||||||
userId: string;
|
|
||||||
status: LicenseStatusType;
|
|
||||||
assignedAt: Date;
|
|
||||||
revokedAt: Date | null;
|
|
||||||
}): License {
|
|
||||||
return new License({
|
|
||||||
id: props.id,
|
|
||||||
subscriptionId: props.subscriptionId,
|
|
||||||
userId: props.userId,
|
|
||||||
status: LicenseStatus.create(props.status),
|
|
||||||
assignedAt: props.assignedAt,
|
|
||||||
revokedAt: props.revokedAt,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Getters
|
|
||||||
get id(): string {
|
|
||||||
return this.props.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
get subscriptionId(): string {
|
|
||||||
return this.props.subscriptionId;
|
|
||||||
}
|
|
||||||
|
|
||||||
get userId(): string {
|
|
||||||
return this.props.userId;
|
|
||||||
}
|
|
||||||
|
|
||||||
get status(): LicenseStatus {
|
|
||||||
return this.props.status;
|
|
||||||
}
|
|
||||||
|
|
||||||
get assignedAt(): Date {
|
|
||||||
return this.props.assignedAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
get revokedAt(): Date | null {
|
|
||||||
return this.props.revokedAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Business logic
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if the license is currently active
|
|
||||||
*/
|
|
||||||
isActive(): boolean {
|
|
||||||
return this.props.status.isActive();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if the license has been revoked
|
|
||||||
*/
|
|
||||||
isRevoked(): boolean {
|
|
||||||
return this.props.status.isRevoked();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Revoke this license
|
|
||||||
*/
|
|
||||||
revoke(): License {
|
|
||||||
if (this.isRevoked()) {
|
|
||||||
throw new Error('License is already revoked');
|
|
||||||
}
|
|
||||||
|
|
||||||
return new License({
|
|
||||||
...this.props,
|
|
||||||
status: LicenseStatus.revoked(),
|
|
||||||
revokedAt: new Date(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reactivate a revoked license
|
|
||||||
*/
|
|
||||||
reactivate(): License {
|
|
||||||
if (this.isActive()) {
|
|
||||||
throw new Error('License is already active');
|
|
||||||
}
|
|
||||||
|
|
||||||
return new License({
|
|
||||||
...this.props,
|
|
||||||
status: LicenseStatus.active(),
|
|
||||||
revokedAt: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the duration the license was/is active
|
|
||||||
*/
|
|
||||||
getActiveDuration(): number {
|
|
||||||
const endTime = this.props.revokedAt ?? new Date();
|
|
||||||
return endTime.getTime() - this.props.assignedAt.getTime();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert to plain object for persistence
|
|
||||||
*/
|
|
||||||
toObject(): {
|
|
||||||
id: string;
|
|
||||||
subscriptionId: string;
|
|
||||||
userId: string;
|
|
||||||
status: LicenseStatusType;
|
|
||||||
assignedAt: Date;
|
|
||||||
revokedAt: Date | null;
|
|
||||||
} {
|
|
||||||
return {
|
|
||||||
id: this.props.id,
|
|
||||||
subscriptionId: this.props.subscriptionId,
|
|
||||||
userId: this.props.userId,
|
|
||||||
status: this.props.status.value,
|
|
||||||
assignedAt: this.props.assignedAt,
|
|
||||||
revokedAt: this.props.revokedAt,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,405 +0,0 @@
|
|||||||
/**
|
|
||||||
* Subscription Entity Tests
|
|
||||||
*
|
|
||||||
* Unit tests for the Subscription domain entity
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Subscription } from './subscription.entity';
|
|
||||||
import { SubscriptionPlan } from '../value-objects/subscription-plan.vo';
|
|
||||||
import { SubscriptionStatus } from '../value-objects/subscription-status.vo';
|
|
||||||
import {
|
|
||||||
InvalidSubscriptionDowngradeException,
|
|
||||||
SubscriptionNotActiveException,
|
|
||||||
} from '../exceptions/subscription.exceptions';
|
|
||||||
|
|
||||||
describe('Subscription Entity', () => {
|
|
||||||
const createValidSubscription = () => {
|
|
||||||
return Subscription.create({
|
|
||||||
id: 'sub-123',
|
|
||||||
organizationId: 'org-123',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('create', () => {
|
|
||||||
it('should create a subscription with default FREE plan', () => {
|
|
||||||
const subscription = createValidSubscription();
|
|
||||||
|
|
||||||
expect(subscription.id).toBe('sub-123');
|
|
||||||
expect(subscription.organizationId).toBe('org-123');
|
|
||||||
expect(subscription.plan.value).toBe('FREE');
|
|
||||||
expect(subscription.status.value).toBe('ACTIVE');
|
|
||||||
expect(subscription.cancelAtPeriodEnd).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create a subscription with custom plan', () => {
|
|
||||||
const subscription = Subscription.create({
|
|
||||||
id: 'sub-123',
|
|
||||||
organizationId: 'org-123',
|
|
||||||
plan: SubscriptionPlan.starter(),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(subscription.plan.value).toBe('STARTER');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create a subscription with Stripe IDs', () => {
|
|
||||||
const subscription = Subscription.create({
|
|
||||||
id: 'sub-123',
|
|
||||||
organizationId: 'org-123',
|
|
||||||
stripeCustomerId: 'cus_123',
|
|
||||||
stripeSubscriptionId: 'sub_stripe_123',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(subscription.stripeCustomerId).toBe('cus_123');
|
|
||||||
expect(subscription.stripeSubscriptionId).toBe('sub_stripe_123');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('fromPersistence', () => {
|
|
||||||
it('should reconstitute a subscription from persistence data', () => {
|
|
||||||
const subscription = Subscription.fromPersistence({
|
|
||||||
id: 'sub-123',
|
|
||||||
organizationId: 'org-123',
|
|
||||||
plan: 'PRO',
|
|
||||||
status: 'ACTIVE',
|
|
||||||
stripeCustomerId: 'cus_123',
|
|
||||||
stripeSubscriptionId: 'sub_stripe_123',
|
|
||||||
currentPeriodStart: new Date('2024-01-01'),
|
|
||||||
currentPeriodEnd: new Date('2024-02-01'),
|
|
||||||
cancelAtPeriodEnd: true,
|
|
||||||
createdAt: new Date('2024-01-01'),
|
|
||||||
updatedAt: new Date('2024-01-15'),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(subscription.id).toBe('sub-123');
|
|
||||||
expect(subscription.plan.value).toBe('PRO');
|
|
||||||
expect(subscription.status.value).toBe('ACTIVE');
|
|
||||||
expect(subscription.cancelAtPeriodEnd).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('maxLicenses', () => {
|
|
||||||
it('should return correct limits for FREE plan', () => {
|
|
||||||
const subscription = createValidSubscription();
|
|
||||||
expect(subscription.maxLicenses).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return correct limits for STARTER plan', () => {
|
|
||||||
const subscription = Subscription.create({
|
|
||||||
id: 'sub-123',
|
|
||||||
organizationId: 'org-123',
|
|
||||||
plan: SubscriptionPlan.starter(),
|
|
||||||
});
|
|
||||||
expect(subscription.maxLicenses).toBe(5);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return correct limits for PRO plan', () => {
|
|
||||||
const subscription = Subscription.create({
|
|
||||||
id: 'sub-123',
|
|
||||||
organizationId: 'org-123',
|
|
||||||
plan: SubscriptionPlan.pro(),
|
|
||||||
});
|
|
||||||
expect(subscription.maxLicenses).toBe(20);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return -1 for ENTERPRISE plan (unlimited)', () => {
|
|
||||||
const subscription = Subscription.create({
|
|
||||||
id: 'sub-123',
|
|
||||||
organizationId: 'org-123',
|
|
||||||
plan: SubscriptionPlan.enterprise(),
|
|
||||||
});
|
|
||||||
expect(subscription.maxLicenses).toBe(-1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('isUnlimited', () => {
|
|
||||||
it('should return false for FREE plan', () => {
|
|
||||||
const subscription = createValidSubscription();
|
|
||||||
expect(subscription.isUnlimited()).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return true for ENTERPRISE plan', () => {
|
|
||||||
const subscription = Subscription.create({
|
|
||||||
id: 'sub-123',
|
|
||||||
organizationId: 'org-123',
|
|
||||||
plan: SubscriptionPlan.enterprise(),
|
|
||||||
});
|
|
||||||
expect(subscription.isUnlimited()).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('isActive', () => {
|
|
||||||
it('should return true for ACTIVE status', () => {
|
|
||||||
const subscription = createValidSubscription();
|
|
||||||
expect(subscription.isActive()).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return true for TRIALING status', () => {
|
|
||||||
const subscription = Subscription.fromPersistence({
|
|
||||||
id: 'sub-123',
|
|
||||||
organizationId: 'org-123',
|
|
||||||
plan: 'FREE',
|
|
||||||
status: 'TRIALING',
|
|
||||||
stripeCustomerId: null,
|
|
||||||
stripeSubscriptionId: null,
|
|
||||||
currentPeriodStart: null,
|
|
||||||
currentPeriodEnd: null,
|
|
||||||
cancelAtPeriodEnd: false,
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
});
|
|
||||||
expect(subscription.isActive()).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false for CANCELED status', () => {
|
|
||||||
const subscription = Subscription.fromPersistence({
|
|
||||||
id: 'sub-123',
|
|
||||||
organizationId: 'org-123',
|
|
||||||
plan: 'FREE',
|
|
||||||
status: 'CANCELED',
|
|
||||||
stripeCustomerId: null,
|
|
||||||
stripeSubscriptionId: null,
|
|
||||||
currentPeriodStart: null,
|
|
||||||
currentPeriodEnd: null,
|
|
||||||
cancelAtPeriodEnd: false,
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
});
|
|
||||||
expect(subscription.isActive()).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('canAllocateLicenses', () => {
|
|
||||||
it('should return true when licenses are available', () => {
|
|
||||||
const subscription = createValidSubscription();
|
|
||||||
expect(subscription.canAllocateLicenses(0, 1)).toBe(true);
|
|
||||||
expect(subscription.canAllocateLicenses(1, 1)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false when no licenses available', () => {
|
|
||||||
const subscription = createValidSubscription();
|
|
||||||
expect(subscription.canAllocateLicenses(2, 1)).toBe(false); // FREE has 2 licenses
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should always return true for ENTERPRISE plan', () => {
|
|
||||||
const subscription = Subscription.create({
|
|
||||||
id: 'sub-123',
|
|
||||||
organizationId: 'org-123',
|
|
||||||
plan: SubscriptionPlan.enterprise(),
|
|
||||||
});
|
|
||||||
expect(subscription.canAllocateLicenses(1000, 100)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false when subscription is not active', () => {
|
|
||||||
const subscription = Subscription.fromPersistence({
|
|
||||||
id: 'sub-123',
|
|
||||||
organizationId: 'org-123',
|
|
||||||
plan: 'FREE',
|
|
||||||
status: 'CANCELED',
|
|
||||||
stripeCustomerId: null,
|
|
||||||
stripeSubscriptionId: null,
|
|
||||||
currentPeriodStart: null,
|
|
||||||
currentPeriodEnd: null,
|
|
||||||
cancelAtPeriodEnd: false,
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
});
|
|
||||||
expect(subscription.canAllocateLicenses(0, 1)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('canUpgradeTo', () => {
|
|
||||||
it('should allow upgrade from FREE to STARTER', () => {
|
|
||||||
const subscription = createValidSubscription();
|
|
||||||
expect(subscription.canUpgradeTo(SubscriptionPlan.starter())).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should allow upgrade from FREE to PRO', () => {
|
|
||||||
const subscription = createValidSubscription();
|
|
||||||
expect(subscription.canUpgradeTo(SubscriptionPlan.pro())).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not allow downgrade via canUpgradeTo', () => {
|
|
||||||
const subscription = Subscription.create({
|
|
||||||
id: 'sub-123',
|
|
||||||
organizationId: 'org-123',
|
|
||||||
plan: SubscriptionPlan.starter(),
|
|
||||||
});
|
|
||||||
expect(subscription.canUpgradeTo(SubscriptionPlan.free())).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('canDowngradeTo', () => {
|
|
||||||
it('should allow downgrade when user count fits', () => {
|
|
||||||
const subscription = Subscription.create({
|
|
||||||
id: 'sub-123',
|
|
||||||
organizationId: 'org-123',
|
|
||||||
plan: SubscriptionPlan.starter(),
|
|
||||||
});
|
|
||||||
expect(subscription.canDowngradeTo(SubscriptionPlan.free(), 1)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should prevent downgrade when user count exceeds new plan', () => {
|
|
||||||
const subscription = Subscription.create({
|
|
||||||
id: 'sub-123',
|
|
||||||
organizationId: 'org-123',
|
|
||||||
plan: SubscriptionPlan.starter(),
|
|
||||||
});
|
|
||||||
expect(subscription.canDowngradeTo(SubscriptionPlan.free(), 5)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('updatePlan', () => {
|
|
||||||
it('should update to new plan when valid', () => {
|
|
||||||
const subscription = createValidSubscription();
|
|
||||||
const updated = subscription.updatePlan(SubscriptionPlan.starter(), 1);
|
|
||||||
|
|
||||||
expect(updated.plan.value).toBe('STARTER');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw when subscription is not active', () => {
|
|
||||||
const subscription = Subscription.fromPersistence({
|
|
||||||
id: 'sub-123',
|
|
||||||
organizationId: 'org-123',
|
|
||||||
plan: 'FREE',
|
|
||||||
status: 'CANCELED',
|
|
||||||
stripeCustomerId: null,
|
|
||||||
stripeSubscriptionId: null,
|
|
||||||
currentPeriodStart: null,
|
|
||||||
currentPeriodEnd: null,
|
|
||||||
cancelAtPeriodEnd: false,
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(() => subscription.updatePlan(SubscriptionPlan.starter(), 0)).toThrow(
|
|
||||||
SubscriptionNotActiveException,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw when downgrading with too many users', () => {
|
|
||||||
const subscription = Subscription.create({
|
|
||||||
id: 'sub-123',
|
|
||||||
organizationId: 'org-123',
|
|
||||||
plan: SubscriptionPlan.pro(),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(() => subscription.updatePlan(SubscriptionPlan.starter(), 10)).toThrow(
|
|
||||||
InvalidSubscriptionDowngradeException,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('updateStatus', () => {
|
|
||||||
it('should update subscription status', () => {
|
|
||||||
const subscription = createValidSubscription();
|
|
||||||
const updated = subscription.updateStatus(SubscriptionStatus.pastDue());
|
|
||||||
|
|
||||||
expect(updated.status.value).toBe('PAST_DUE');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('updateStripeCustomerId', () => {
|
|
||||||
it('should update Stripe customer ID', () => {
|
|
||||||
const subscription = createValidSubscription();
|
|
||||||
const updated = subscription.updateStripeCustomerId('cus_new_123');
|
|
||||||
|
|
||||||
expect(updated.stripeCustomerId).toBe('cus_new_123');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('updateStripeSubscription', () => {
|
|
||||||
it('should update Stripe subscription details', () => {
|
|
||||||
const subscription = createValidSubscription();
|
|
||||||
const periodStart = new Date('2024-02-01');
|
|
||||||
const periodEnd = new Date('2024-03-01');
|
|
||||||
|
|
||||||
const updated = subscription.updateStripeSubscription({
|
|
||||||
stripeSubscriptionId: 'sub_new_123',
|
|
||||||
currentPeriodStart: periodStart,
|
|
||||||
currentPeriodEnd: periodEnd,
|
|
||||||
cancelAtPeriodEnd: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(updated.stripeSubscriptionId).toBe('sub_new_123');
|
|
||||||
expect(updated.currentPeriodStart).toEqual(periodStart);
|
|
||||||
expect(updated.currentPeriodEnd).toEqual(periodEnd);
|
|
||||||
expect(updated.cancelAtPeriodEnd).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('scheduleCancellation', () => {
|
|
||||||
it('should mark subscription for cancellation', () => {
|
|
||||||
const subscription = createValidSubscription();
|
|
||||||
const updated = subscription.scheduleCancellation();
|
|
||||||
|
|
||||||
expect(updated.cancelAtPeriodEnd).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('unscheduleCancellation', () => {
|
|
||||||
it('should unmark subscription for cancellation', () => {
|
|
||||||
const subscription = Subscription.fromPersistence({
|
|
||||||
id: 'sub-123',
|
|
||||||
organizationId: 'org-123',
|
|
||||||
plan: 'STARTER',
|
|
||||||
status: 'ACTIVE',
|
|
||||||
stripeCustomerId: 'cus_123',
|
|
||||||
stripeSubscriptionId: 'sub_123',
|
|
||||||
currentPeriodStart: new Date(),
|
|
||||||
currentPeriodEnd: new Date(),
|
|
||||||
cancelAtPeriodEnd: true,
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const updated = subscription.unscheduleCancellation();
|
|
||||||
expect(updated.cancelAtPeriodEnd).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('cancel', () => {
|
|
||||||
it('should cancel the subscription immediately', () => {
|
|
||||||
const subscription = createValidSubscription();
|
|
||||||
const updated = subscription.cancel();
|
|
||||||
|
|
||||||
expect(updated.status.value).toBe('CANCELED');
|
|
||||||
expect(updated.cancelAtPeriodEnd).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('isFree and isPaid', () => {
|
|
||||||
it('should return true for isFree when FREE plan', () => {
|
|
||||||
const subscription = createValidSubscription();
|
|
||||||
expect(subscription.isFree()).toBe(true);
|
|
||||||
expect(subscription.isPaid()).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return true for isPaid when STARTER plan', () => {
|
|
||||||
const subscription = Subscription.create({
|
|
||||||
id: 'sub-123',
|
|
||||||
organizationId: 'org-123',
|
|
||||||
plan: SubscriptionPlan.starter(),
|
|
||||||
});
|
|
||||||
expect(subscription.isFree()).toBe(false);
|
|
||||||
expect(subscription.isPaid()).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('toObject', () => {
|
|
||||||
it('should convert to plain object for persistence', () => {
|
|
||||||
const subscription = Subscription.create({
|
|
||||||
id: 'sub-123',
|
|
||||||
organizationId: 'org-123',
|
|
||||||
stripeCustomerId: 'cus_123',
|
|
||||||
});
|
|
||||||
|
|
||||||
const obj = subscription.toObject();
|
|
||||||
|
|
||||||
expect(obj.id).toBe('sub-123');
|
|
||||||
expect(obj.organizationId).toBe('org-123');
|
|
||||||
expect(obj.plan).toBe('FREE');
|
|
||||||
expect(obj.status).toBe('ACTIVE');
|
|
||||||
expect(obj.stripeCustomerId).toBe('cus_123');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,355 +0,0 @@
|
|||||||
/**
|
|
||||||
* Subscription Entity
|
|
||||||
*
|
|
||||||
* Represents an organization's subscription, including their plan,
|
|
||||||
* Stripe integration, and billing period information.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {
|
|
||||||
SubscriptionPlan,
|
|
||||||
SubscriptionPlanType,
|
|
||||||
} from '../value-objects/subscription-plan.vo';
|
|
||||||
import {
|
|
||||||
SubscriptionStatus,
|
|
||||||
SubscriptionStatusType,
|
|
||||||
} from '../value-objects/subscription-status.vo';
|
|
||||||
import {
|
|
||||||
InvalidSubscriptionDowngradeException,
|
|
||||||
SubscriptionNotActiveException,
|
|
||||||
} from '../exceptions/subscription.exceptions';
|
|
||||||
|
|
||||||
export interface SubscriptionProps {
|
|
||||||
readonly id: string;
|
|
||||||
readonly organizationId: string;
|
|
||||||
readonly plan: SubscriptionPlan;
|
|
||||||
readonly status: SubscriptionStatus;
|
|
||||||
readonly stripeCustomerId: string | null;
|
|
||||||
readonly stripeSubscriptionId: string | null;
|
|
||||||
readonly currentPeriodStart: Date | null;
|
|
||||||
readonly currentPeriodEnd: Date | null;
|
|
||||||
readonly cancelAtPeriodEnd: boolean;
|
|
||||||
readonly createdAt: Date;
|
|
||||||
readonly updatedAt: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Subscription {
|
|
||||||
private readonly props: SubscriptionProps;
|
|
||||||
|
|
||||||
private constructor(props: SubscriptionProps) {
|
|
||||||
this.props = props;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new subscription (defaults to FREE plan)
|
|
||||||
*/
|
|
||||||
static create(props: {
|
|
||||||
id: string;
|
|
||||||
organizationId: string;
|
|
||||||
plan?: SubscriptionPlan;
|
|
||||||
stripeCustomerId?: string | null;
|
|
||||||
stripeSubscriptionId?: string | null;
|
|
||||||
}): Subscription {
|
|
||||||
const now = new Date();
|
|
||||||
return new Subscription({
|
|
||||||
id: props.id,
|
|
||||||
organizationId: props.organizationId,
|
|
||||||
plan: props.plan ?? SubscriptionPlan.free(),
|
|
||||||
status: SubscriptionStatus.active(),
|
|
||||||
stripeCustomerId: props.stripeCustomerId ?? null,
|
|
||||||
stripeSubscriptionId: props.stripeSubscriptionId ?? null,
|
|
||||||
currentPeriodStart: null,
|
|
||||||
currentPeriodEnd: null,
|
|
||||||
cancelAtPeriodEnd: false,
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reconstitute from persistence
|
|
||||||
*/
|
|
||||||
static fromPersistence(props: {
|
|
||||||
id: string;
|
|
||||||
organizationId: string;
|
|
||||||
plan: SubscriptionPlanType;
|
|
||||||
status: SubscriptionStatusType;
|
|
||||||
stripeCustomerId: string | null;
|
|
||||||
stripeSubscriptionId: string | null;
|
|
||||||
currentPeriodStart: Date | null;
|
|
||||||
currentPeriodEnd: Date | null;
|
|
||||||
cancelAtPeriodEnd: boolean;
|
|
||||||
createdAt: Date;
|
|
||||||
updatedAt: Date;
|
|
||||||
}): Subscription {
|
|
||||||
return new Subscription({
|
|
||||||
id: props.id,
|
|
||||||
organizationId: props.organizationId,
|
|
||||||
plan: SubscriptionPlan.create(props.plan),
|
|
||||||
status: SubscriptionStatus.create(props.status),
|
|
||||||
stripeCustomerId: props.stripeCustomerId,
|
|
||||||
stripeSubscriptionId: props.stripeSubscriptionId,
|
|
||||||
currentPeriodStart: props.currentPeriodStart,
|
|
||||||
currentPeriodEnd: props.currentPeriodEnd,
|
|
||||||
cancelAtPeriodEnd: props.cancelAtPeriodEnd,
|
|
||||||
createdAt: props.createdAt,
|
|
||||||
updatedAt: props.updatedAt,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Getters
|
|
||||||
get id(): string {
|
|
||||||
return this.props.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
get organizationId(): string {
|
|
||||||
return this.props.organizationId;
|
|
||||||
}
|
|
||||||
|
|
||||||
get plan(): SubscriptionPlan {
|
|
||||||
return this.props.plan;
|
|
||||||
}
|
|
||||||
|
|
||||||
get status(): SubscriptionStatus {
|
|
||||||
return this.props.status;
|
|
||||||
}
|
|
||||||
|
|
||||||
get stripeCustomerId(): string | null {
|
|
||||||
return this.props.stripeCustomerId;
|
|
||||||
}
|
|
||||||
|
|
||||||
get stripeSubscriptionId(): string | null {
|
|
||||||
return this.props.stripeSubscriptionId;
|
|
||||||
}
|
|
||||||
|
|
||||||
get currentPeriodStart(): Date | null {
|
|
||||||
return this.props.currentPeriodStart;
|
|
||||||
}
|
|
||||||
|
|
||||||
get currentPeriodEnd(): Date | null {
|
|
||||||
return this.props.currentPeriodEnd;
|
|
||||||
}
|
|
||||||
|
|
||||||
get cancelAtPeriodEnd(): boolean {
|
|
||||||
return this.props.cancelAtPeriodEnd;
|
|
||||||
}
|
|
||||||
|
|
||||||
get createdAt(): Date {
|
|
||||||
return this.props.createdAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
get updatedAt(): Date {
|
|
||||||
return this.props.updatedAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Business logic
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the maximum number of licenses allowed by this subscription
|
|
||||||
*/
|
|
||||||
get maxLicenses(): number {
|
|
||||||
return this.props.plan.maxLicenses;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if the subscription has unlimited licenses
|
|
||||||
*/
|
|
||||||
isUnlimited(): boolean {
|
|
||||||
return this.props.plan.isUnlimited();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if the subscription is active and allows access
|
|
||||||
*/
|
|
||||||
isActive(): boolean {
|
|
||||||
return this.props.status.allowsAccess();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if the subscription is in good standing
|
|
||||||
*/
|
|
||||||
isInGoodStanding(): boolean {
|
|
||||||
return this.props.status.isInGoodStanding();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if the subscription requires user action
|
|
||||||
*/
|
|
||||||
requiresAction(): boolean {
|
|
||||||
return this.props.status.requiresAction();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if this is a free subscription
|
|
||||||
*/
|
|
||||||
isFree(): boolean {
|
|
||||||
return this.props.plan.isFree();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if this is a paid subscription
|
|
||||||
*/
|
|
||||||
isPaid(): boolean {
|
|
||||||
return this.props.plan.isPaid();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if the subscription is scheduled to be canceled
|
|
||||||
*/
|
|
||||||
isScheduledForCancellation(): boolean {
|
|
||||||
return this.props.cancelAtPeriodEnd;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a given number of licenses can be allocated
|
|
||||||
*/
|
|
||||||
canAllocateLicenses(currentCount: number, additionalCount: number = 1): boolean {
|
|
||||||
if (!this.isActive()) return false;
|
|
||||||
if (this.isUnlimited()) return true;
|
|
||||||
return currentCount + additionalCount <= this.maxLicenses;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if upgrade to target plan is possible
|
|
||||||
*/
|
|
||||||
canUpgradeTo(targetPlan: SubscriptionPlan): boolean {
|
|
||||||
return this.props.plan.canUpgradeTo(targetPlan);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if downgrade to target plan is possible given current user count
|
|
||||||
*/
|
|
||||||
canDowngradeTo(targetPlan: SubscriptionPlan, currentUserCount: number): boolean {
|
|
||||||
return this.props.plan.canDowngradeTo(targetPlan, currentUserCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update the subscription plan
|
|
||||||
*/
|
|
||||||
updatePlan(newPlan: SubscriptionPlan, currentUserCount: number): Subscription {
|
|
||||||
if (!this.isActive()) {
|
|
||||||
throw new SubscriptionNotActiveException(this.props.id, this.props.status.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if downgrade is valid
|
|
||||||
if (!newPlan.canAccommodateUsers(currentUserCount)) {
|
|
||||||
throw new InvalidSubscriptionDowngradeException(
|
|
||||||
this.props.plan.value,
|
|
||||||
newPlan.value,
|
|
||||||
currentUserCount,
|
|
||||||
newPlan.maxLicenses,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Subscription({
|
|
||||||
...this.props,
|
|
||||||
plan: newPlan,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update subscription status
|
|
||||||
*/
|
|
||||||
updateStatus(newStatus: SubscriptionStatus): Subscription {
|
|
||||||
return new Subscription({
|
|
||||||
...this.props,
|
|
||||||
status: newStatus,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update Stripe customer ID
|
|
||||||
*/
|
|
||||||
updateStripeCustomerId(stripeCustomerId: string): Subscription {
|
|
||||||
return new Subscription({
|
|
||||||
...this.props,
|
|
||||||
stripeCustomerId,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update Stripe subscription details
|
|
||||||
*/
|
|
||||||
updateStripeSubscription(params: {
|
|
||||||
stripeSubscriptionId: string;
|
|
||||||
currentPeriodStart: Date;
|
|
||||||
currentPeriodEnd: Date;
|
|
||||||
cancelAtPeriodEnd?: boolean;
|
|
||||||
}): Subscription {
|
|
||||||
return new Subscription({
|
|
||||||
...this.props,
|
|
||||||
stripeSubscriptionId: params.stripeSubscriptionId,
|
|
||||||
currentPeriodStart: params.currentPeriodStart,
|
|
||||||
currentPeriodEnd: params.currentPeriodEnd,
|
|
||||||
cancelAtPeriodEnd: params.cancelAtPeriodEnd ?? this.props.cancelAtPeriodEnd,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mark subscription as scheduled for cancellation at period end
|
|
||||||
*/
|
|
||||||
scheduleCancellation(): Subscription {
|
|
||||||
return new Subscription({
|
|
||||||
...this.props,
|
|
||||||
cancelAtPeriodEnd: true,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unschedule cancellation
|
|
||||||
*/
|
|
||||||
unscheduleCancellation(): Subscription {
|
|
||||||
return new Subscription({
|
|
||||||
...this.props,
|
|
||||||
cancelAtPeriodEnd: false,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cancel the subscription immediately
|
|
||||||
*/
|
|
||||||
cancel(): Subscription {
|
|
||||||
return new Subscription({
|
|
||||||
...this.props,
|
|
||||||
status: SubscriptionStatus.canceled(),
|
|
||||||
cancelAtPeriodEnd: false,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert to plain object for persistence
|
|
||||||
*/
|
|
||||||
toObject(): {
|
|
||||||
id: string;
|
|
||||||
organizationId: string;
|
|
||||||
plan: SubscriptionPlanType;
|
|
||||||
status: SubscriptionStatusType;
|
|
||||||
stripeCustomerId: string | null;
|
|
||||||
stripeSubscriptionId: string | null;
|
|
||||||
currentPeriodStart: Date | null;
|
|
||||||
currentPeriodEnd: Date | null;
|
|
||||||
cancelAtPeriodEnd: boolean;
|
|
||||||
createdAt: Date;
|
|
||||||
updatedAt: Date;
|
|
||||||
} {
|
|
||||||
return {
|
|
||||||
id: this.props.id,
|
|
||||||
organizationId: this.props.organizationId,
|
|
||||||
plan: this.props.plan.value,
|
|
||||||
status: this.props.status.value,
|
|
||||||
stripeCustomerId: this.props.stripeCustomerId,
|
|
||||||
stripeSubscriptionId: this.props.stripeSubscriptionId,
|
|
||||||
currentPeriodStart: this.props.currentPeriodStart,
|
|
||||||
currentPeriodEnd: this.props.currentPeriodEnd,
|
|
||||||
cancelAtPeriodEnd: this.props.cancelAtPeriodEnd,
|
|
||||||
createdAt: this.props.createdAt,
|
|
||||||
updatedAt: this.props.updatedAt,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -10,4 +10,3 @@ export * from './carrier-timeout.exception';
|
|||||||
export * from './carrier-unavailable.exception';
|
export * from './carrier-unavailable.exception';
|
||||||
export * from './rate-quote-expired.exception';
|
export * from './rate-quote-expired.exception';
|
||||||
export * from './port-not-found.exception';
|
export * from './port-not-found.exception';
|
||||||
export * from './subscription.exceptions';
|
|
||||||
|
|||||||
@ -1,85 +0,0 @@
|
|||||||
/**
|
|
||||||
* Subscription Domain Exceptions
|
|
||||||
*/
|
|
||||||
|
|
||||||
export class NoLicensesAvailableException extends Error {
|
|
||||||
constructor(
|
|
||||||
public readonly organizationId: string,
|
|
||||||
public readonly currentLicenses: number,
|
|
||||||
public readonly maxLicenses: number,
|
|
||||||
) {
|
|
||||||
super(
|
|
||||||
`No licenses available for organization ${organizationId}. ` +
|
|
||||||
`Currently using ${currentLicenses}/${maxLicenses} licenses.`,
|
|
||||||
);
|
|
||||||
this.name = 'NoLicensesAvailableException';
|
|
||||||
Object.setPrototypeOf(this, NoLicensesAvailableException.prototype);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class SubscriptionNotFoundException extends Error {
|
|
||||||
constructor(public readonly identifier: string) {
|
|
||||||
super(`Subscription not found: ${identifier}`);
|
|
||||||
this.name = 'SubscriptionNotFoundException';
|
|
||||||
Object.setPrototypeOf(this, SubscriptionNotFoundException.prototype);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class LicenseNotFoundException extends Error {
|
|
||||||
constructor(public readonly identifier: string) {
|
|
||||||
super(`License not found: ${identifier}`);
|
|
||||||
this.name = 'LicenseNotFoundException';
|
|
||||||
Object.setPrototypeOf(this, LicenseNotFoundException.prototype);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class LicenseAlreadyAssignedException extends Error {
|
|
||||||
constructor(public readonly userId: string) {
|
|
||||||
super(`User ${userId} already has an assigned license`);
|
|
||||||
this.name = 'LicenseAlreadyAssignedException';
|
|
||||||
Object.setPrototypeOf(this, LicenseAlreadyAssignedException.prototype);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class InvalidSubscriptionDowngradeException extends Error {
|
|
||||||
constructor(
|
|
||||||
public readonly currentPlan: string,
|
|
||||||
public readonly targetPlan: string,
|
|
||||||
public readonly currentUsers: number,
|
|
||||||
public readonly targetMaxLicenses: number,
|
|
||||||
) {
|
|
||||||
super(
|
|
||||||
`Cannot downgrade from ${currentPlan} to ${targetPlan}. ` +
|
|
||||||
`Current users (${currentUsers}) exceed target plan limit (${targetMaxLicenses}).`,
|
|
||||||
);
|
|
||||||
this.name = 'InvalidSubscriptionDowngradeException';
|
|
||||||
Object.setPrototypeOf(this, InvalidSubscriptionDowngradeException.prototype);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class SubscriptionNotActiveException extends Error {
|
|
||||||
constructor(
|
|
||||||
public readonly subscriptionId: string,
|
|
||||||
public readonly currentStatus: string,
|
|
||||||
) {
|
|
||||||
super(
|
|
||||||
`Subscription ${subscriptionId} is not active. Current status: ${currentStatus}`,
|
|
||||||
);
|
|
||||||
this.name = 'SubscriptionNotActiveException';
|
|
||||||
Object.setPrototypeOf(this, SubscriptionNotActiveException.prototype);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class InvalidSubscriptionStatusTransitionException extends Error {
|
|
||||||
constructor(
|
|
||||||
public readonly fromStatus: string,
|
|
||||||
public readonly toStatus: string,
|
|
||||||
) {
|
|
||||||
super(`Invalid subscription status transition from ${fromStatus} to ${toStatus}`);
|
|
||||||
this.name = 'InvalidSubscriptionStatusTransitionException';
|
|
||||||
Object.setPrototypeOf(
|
|
||||||
this,
|
|
||||||
InvalidSubscriptionStatusTransitionException.prototype,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -23,6 +23,3 @@ export * from './pdf.port';
|
|||||||
export * from './storage.port';
|
export * from './storage.port';
|
||||||
export * from './carrier-connector.port';
|
export * from './carrier-connector.port';
|
||||||
export * from './csv-rate-loader.port';
|
export * from './csv-rate-loader.port';
|
||||||
export * from './subscription.repository';
|
|
||||||
export * from './license.repository';
|
|
||||||
export * from './stripe.port';
|
|
||||||
|
|||||||
@ -1,62 +0,0 @@
|
|||||||
/**
|
|
||||||
* License Repository Port
|
|
||||||
*
|
|
||||||
* Interface for license persistence operations.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { License } from '../../entities/license.entity';
|
|
||||||
|
|
||||||
export const LICENSE_REPOSITORY = 'LICENSE_REPOSITORY';
|
|
||||||
|
|
||||||
export interface LicenseRepository {
|
|
||||||
/**
|
|
||||||
* Save a license (create or update)
|
|
||||||
*/
|
|
||||||
save(license: License): Promise<License>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find a license by its ID
|
|
||||||
*/
|
|
||||||
findById(id: string): Promise<License | null>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find a license by user ID
|
|
||||||
*/
|
|
||||||
findByUserId(userId: string): Promise<License | null>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find all licenses for a subscription
|
|
||||||
*/
|
|
||||||
findBySubscriptionId(subscriptionId: string): Promise<License[]>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find all active licenses for a subscription
|
|
||||||
*/
|
|
||||||
findActiveBySubscriptionId(subscriptionId: string): Promise<License[]>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Count active licenses for a subscription
|
|
||||||
*/
|
|
||||||
countActiveBySubscriptionId(subscriptionId: string): Promise<number>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Count active licenses for a subscription, excluding ADMIN users
|
|
||||||
* ADMIN users have unlimited licenses and don't consume the organization's quota
|
|
||||||
*/
|
|
||||||
countActiveBySubscriptionIdExcludingAdmins(subscriptionId: string): Promise<number>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find all active licenses for a subscription, excluding ADMIN users
|
|
||||||
*/
|
|
||||||
findActiveBySubscriptionIdExcludingAdmins(subscriptionId: string): Promise<License[]>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a license
|
|
||||||
*/
|
|
||||||
delete(id: string): Promise<void>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete all licenses for a subscription
|
|
||||||
*/
|
|
||||||
deleteBySubscriptionId(subscriptionId: string): Promise<void>;
|
|
||||||
}
|
|
||||||
@ -1,113 +0,0 @@
|
|||||||
/**
|
|
||||||
* Stripe Port
|
|
||||||
*
|
|
||||||
* Interface for Stripe payment integration.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { SubscriptionPlanType } from '../../value-objects/subscription-plan.vo';
|
|
||||||
|
|
||||||
export const STRIPE_PORT = 'STRIPE_PORT';
|
|
||||||
|
|
||||||
export interface CreateCheckoutSessionInput {
|
|
||||||
organizationId: string;
|
|
||||||
organizationName: string;
|
|
||||||
email: string;
|
|
||||||
plan: SubscriptionPlanType;
|
|
||||||
billingInterval: 'monthly' | 'yearly';
|
|
||||||
successUrl: string;
|
|
||||||
cancelUrl: string;
|
|
||||||
customerId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateCheckoutSessionOutput {
|
|
||||||
sessionId: string;
|
|
||||||
sessionUrl: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreatePortalSessionInput {
|
|
||||||
customerId: string;
|
|
||||||
returnUrl: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreatePortalSessionOutput {
|
|
||||||
sessionUrl: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StripeSubscriptionData {
|
|
||||||
subscriptionId: string;
|
|
||||||
customerId: string;
|
|
||||||
status: string;
|
|
||||||
planId: string;
|
|
||||||
currentPeriodStart: Date;
|
|
||||||
currentPeriodEnd: Date;
|
|
||||||
cancelAtPeriodEnd: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StripeCheckoutSessionData {
|
|
||||||
sessionId: string;
|
|
||||||
customerId: string | null;
|
|
||||||
subscriptionId: string | null;
|
|
||||||
status: string;
|
|
||||||
metadata: Record<string, string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StripeWebhookEvent {
|
|
||||||
type: string;
|
|
||||||
data: {
|
|
||||||
object: Record<string, unknown>;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StripePort {
|
|
||||||
/**
|
|
||||||
* Create a Stripe Checkout session for subscription purchase
|
|
||||||
*/
|
|
||||||
createCheckoutSession(
|
|
||||||
input: CreateCheckoutSessionInput,
|
|
||||||
): Promise<CreateCheckoutSessionOutput>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a Stripe Customer Portal session for subscription management
|
|
||||||
*/
|
|
||||||
createPortalSession(
|
|
||||||
input: CreatePortalSessionInput,
|
|
||||||
): Promise<CreatePortalSessionOutput>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieve subscription details from Stripe
|
|
||||||
*/
|
|
||||||
getSubscription(subscriptionId: string): Promise<StripeSubscriptionData | null>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieve checkout session details from Stripe
|
|
||||||
*/
|
|
||||||
getCheckoutSession(sessionId: string): Promise<StripeCheckoutSessionData | null>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cancel a subscription at period end
|
|
||||||
*/
|
|
||||||
cancelSubscriptionAtPeriodEnd(subscriptionId: string): Promise<void>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cancel a subscription immediately
|
|
||||||
*/
|
|
||||||
cancelSubscriptionImmediately(subscriptionId: string): Promise<void>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resume a canceled subscription
|
|
||||||
*/
|
|
||||||
resumeSubscription(subscriptionId: string): Promise<void>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verify and parse a Stripe webhook event
|
|
||||||
*/
|
|
||||||
constructWebhookEvent(
|
|
||||||
payload: string | Buffer,
|
|
||||||
signature: string,
|
|
||||||
): Promise<StripeWebhookEvent>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Map a Stripe price ID to a subscription plan
|
|
||||||
*/
|
|
||||||
mapPriceIdToPlan(priceId: string): SubscriptionPlanType | null;
|
|
||||||
}
|
|
||||||
@ -1,46 +0,0 @@
|
|||||||
/**
|
|
||||||
* Subscription Repository Port
|
|
||||||
*
|
|
||||||
* Interface for subscription persistence operations.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Subscription } from '../../entities/subscription.entity';
|
|
||||||
|
|
||||||
export const SUBSCRIPTION_REPOSITORY = 'SUBSCRIPTION_REPOSITORY';
|
|
||||||
|
|
||||||
export interface SubscriptionRepository {
|
|
||||||
/**
|
|
||||||
* Save a subscription (create or update)
|
|
||||||
*/
|
|
||||||
save(subscription: Subscription): Promise<Subscription>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find a subscription by its ID
|
|
||||||
*/
|
|
||||||
findById(id: string): Promise<Subscription | null>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find a subscription by organization ID
|
|
||||||
*/
|
|
||||||
findByOrganizationId(organizationId: string): Promise<Subscription | null>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find a subscription by Stripe subscription ID
|
|
||||||
*/
|
|
||||||
findByStripeSubscriptionId(stripeSubscriptionId: string): Promise<Subscription | null>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find a subscription by Stripe customer ID
|
|
||||||
*/
|
|
||||||
findByStripeCustomerId(stripeCustomerId: string): Promise<Subscription | null>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find all subscriptions
|
|
||||||
*/
|
|
||||||
findAll(): Promise<Subscription[]>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a subscription
|
|
||||||
*/
|
|
||||||
delete(id: string): Promise<void>;
|
|
||||||
}
|
|
||||||
@ -11,6 +11,3 @@ export * from './container-type.vo';
|
|||||||
export * from './date-range.vo';
|
export * from './date-range.vo';
|
||||||
export * from './booking-number.vo';
|
export * from './booking-number.vo';
|
||||||
export * from './booking-status.vo';
|
export * from './booking-status.vo';
|
||||||
export * from './subscription-plan.vo';
|
|
||||||
export * from './subscription-status.vo';
|
|
||||||
export * from './license-status.vo';
|
|
||||||
|
|||||||
@ -1,74 +0,0 @@
|
|||||||
/**
|
|
||||||
* License Status Value Object
|
|
||||||
*
|
|
||||||
* Represents the status of a user license within a subscription.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export type LicenseStatusType = 'ACTIVE' | 'REVOKED';
|
|
||||||
|
|
||||||
export class LicenseStatus {
|
|
||||||
private constructor(private readonly status: LicenseStatusType) {}
|
|
||||||
|
|
||||||
static create(status: LicenseStatusType): LicenseStatus {
|
|
||||||
if (status !== 'ACTIVE' && status !== 'REVOKED') {
|
|
||||||
throw new Error(`Invalid license status: ${status}`);
|
|
||||||
}
|
|
||||||
return new LicenseStatus(status);
|
|
||||||
}
|
|
||||||
|
|
||||||
static fromString(value: string): LicenseStatus {
|
|
||||||
const upperValue = value.toUpperCase() as LicenseStatusType;
|
|
||||||
if (upperValue !== 'ACTIVE' && upperValue !== 'REVOKED') {
|
|
||||||
throw new Error(`Invalid license status: ${value}`);
|
|
||||||
}
|
|
||||||
return new LicenseStatus(upperValue);
|
|
||||||
}
|
|
||||||
|
|
||||||
static active(): LicenseStatus {
|
|
||||||
return new LicenseStatus('ACTIVE');
|
|
||||||
}
|
|
||||||
|
|
||||||
static revoked(): LicenseStatus {
|
|
||||||
return new LicenseStatus('REVOKED');
|
|
||||||
}
|
|
||||||
|
|
||||||
get value(): LicenseStatusType {
|
|
||||||
return this.status;
|
|
||||||
}
|
|
||||||
|
|
||||||
isActive(): boolean {
|
|
||||||
return this.status === 'ACTIVE';
|
|
||||||
}
|
|
||||||
|
|
||||||
isRevoked(): boolean {
|
|
||||||
return this.status === 'REVOKED';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Revoke this license, returning a new revoked status
|
|
||||||
*/
|
|
||||||
revoke(): LicenseStatus {
|
|
||||||
if (this.status === 'REVOKED') {
|
|
||||||
throw new Error('License is already revoked');
|
|
||||||
}
|
|
||||||
return LicenseStatus.revoked();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reactivate this license, returning a new active status
|
|
||||||
*/
|
|
||||||
reactivate(): LicenseStatus {
|
|
||||||
if (this.status === 'ACTIVE') {
|
|
||||||
throw new Error('License is already active');
|
|
||||||
}
|
|
||||||
return LicenseStatus.active();
|
|
||||||
}
|
|
||||||
|
|
||||||
equals(other: LicenseStatus): boolean {
|
|
||||||
return this.status === other.status;
|
|
||||||
}
|
|
||||||
|
|
||||||
toString(): string {
|
|
||||||
return this.status;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,223 +0,0 @@
|
|||||||
/**
|
|
||||||
* SubscriptionPlan Value Object Tests
|
|
||||||
*
|
|
||||||
* Unit tests for the SubscriptionPlan value object
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { SubscriptionPlan } from './subscription-plan.vo';
|
|
||||||
|
|
||||||
describe('SubscriptionPlan Value Object', () => {
|
|
||||||
describe('static factory methods', () => {
|
|
||||||
it('should create FREE plan', () => {
|
|
||||||
const plan = SubscriptionPlan.free();
|
|
||||||
expect(plan.value).toBe('FREE');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create STARTER plan', () => {
|
|
||||||
const plan = SubscriptionPlan.starter();
|
|
||||||
expect(plan.value).toBe('STARTER');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create PRO plan', () => {
|
|
||||||
const plan = SubscriptionPlan.pro();
|
|
||||||
expect(plan.value).toBe('PRO');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create ENTERPRISE plan', () => {
|
|
||||||
const plan = SubscriptionPlan.enterprise();
|
|
||||||
expect(plan.value).toBe('ENTERPRISE');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('create', () => {
|
|
||||||
it('should create plan from valid type', () => {
|
|
||||||
const plan = SubscriptionPlan.create('STARTER');
|
|
||||||
expect(plan.value).toBe('STARTER');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw for invalid plan type', () => {
|
|
||||||
expect(() => SubscriptionPlan.create('INVALID' as any)).toThrow('Invalid subscription plan');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('fromString', () => {
|
|
||||||
it('should create plan from lowercase string', () => {
|
|
||||||
const plan = SubscriptionPlan.fromString('starter');
|
|
||||||
expect(plan.value).toBe('STARTER');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw for invalid string', () => {
|
|
||||||
expect(() => SubscriptionPlan.fromString('invalid')).toThrow('Invalid subscription plan');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('maxLicenses', () => {
|
|
||||||
it('should return 2 for FREE plan', () => {
|
|
||||||
const plan = SubscriptionPlan.free();
|
|
||||||
expect(plan.maxLicenses).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return 5 for STARTER plan', () => {
|
|
||||||
const plan = SubscriptionPlan.starter();
|
|
||||||
expect(plan.maxLicenses).toBe(5);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return 20 for PRO plan', () => {
|
|
||||||
const plan = SubscriptionPlan.pro();
|
|
||||||
expect(plan.maxLicenses).toBe(20);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return -1 (unlimited) for ENTERPRISE plan', () => {
|
|
||||||
const plan = SubscriptionPlan.enterprise();
|
|
||||||
expect(plan.maxLicenses).toBe(-1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('isUnlimited', () => {
|
|
||||||
it('should return false for FREE plan', () => {
|
|
||||||
expect(SubscriptionPlan.free().isUnlimited()).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false for STARTER plan', () => {
|
|
||||||
expect(SubscriptionPlan.starter().isUnlimited()).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false for PRO plan', () => {
|
|
||||||
expect(SubscriptionPlan.pro().isUnlimited()).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return true for ENTERPRISE plan', () => {
|
|
||||||
expect(SubscriptionPlan.enterprise().isUnlimited()).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('isPaid', () => {
|
|
||||||
it('should return false for FREE plan', () => {
|
|
||||||
expect(SubscriptionPlan.free().isPaid()).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return true for STARTER plan', () => {
|
|
||||||
expect(SubscriptionPlan.starter().isPaid()).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return true for PRO plan', () => {
|
|
||||||
expect(SubscriptionPlan.pro().isPaid()).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return true for ENTERPRISE plan', () => {
|
|
||||||
expect(SubscriptionPlan.enterprise().isPaid()).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('isFree', () => {
|
|
||||||
it('should return true for FREE plan', () => {
|
|
||||||
expect(SubscriptionPlan.free().isFree()).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false for STARTER plan', () => {
|
|
||||||
expect(SubscriptionPlan.starter().isFree()).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('canAccommodateUsers', () => {
|
|
||||||
it('should return true for FREE plan with 2 users', () => {
|
|
||||||
expect(SubscriptionPlan.free().canAccommodateUsers(2)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false for FREE plan with 3 users', () => {
|
|
||||||
expect(SubscriptionPlan.free().canAccommodateUsers(3)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return true for STARTER plan with 5 users', () => {
|
|
||||||
expect(SubscriptionPlan.starter().canAccommodateUsers(5)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should always return true for ENTERPRISE plan', () => {
|
|
||||||
expect(SubscriptionPlan.enterprise().canAccommodateUsers(1000)).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('canUpgradeTo', () => {
|
|
||||||
it('should allow upgrade from FREE to STARTER', () => {
|
|
||||||
expect(SubscriptionPlan.free().canUpgradeTo(SubscriptionPlan.starter())).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should allow upgrade from FREE to PRO', () => {
|
|
||||||
expect(SubscriptionPlan.free().canUpgradeTo(SubscriptionPlan.pro())).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should allow upgrade from FREE to ENTERPRISE', () => {
|
|
||||||
expect(SubscriptionPlan.free().canUpgradeTo(SubscriptionPlan.enterprise())).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should allow upgrade from STARTER to PRO', () => {
|
|
||||||
expect(SubscriptionPlan.starter().canUpgradeTo(SubscriptionPlan.pro())).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not allow downgrade from STARTER to FREE', () => {
|
|
||||||
expect(SubscriptionPlan.starter().canUpgradeTo(SubscriptionPlan.free())).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not allow same plan upgrade', () => {
|
|
||||||
expect(SubscriptionPlan.pro().canUpgradeTo(SubscriptionPlan.pro())).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('canDowngradeTo', () => {
|
|
||||||
it('should allow downgrade from STARTER to FREE when users fit', () => {
|
|
||||||
expect(SubscriptionPlan.starter().canDowngradeTo(SubscriptionPlan.free(), 1)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not allow downgrade from STARTER to FREE when users exceed', () => {
|
|
||||||
expect(SubscriptionPlan.starter().canDowngradeTo(SubscriptionPlan.free(), 5)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not allow upgrade via canDowngradeTo', () => {
|
|
||||||
expect(SubscriptionPlan.free().canDowngradeTo(SubscriptionPlan.starter(), 1)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('plan details', () => {
|
|
||||||
it('should return correct name for FREE plan', () => {
|
|
||||||
expect(SubscriptionPlan.free().name).toBe('Free');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return correct prices for STARTER plan', () => {
|
|
||||||
const plan = SubscriptionPlan.starter();
|
|
||||||
expect(plan.monthlyPriceEur).toBe(49);
|
|
||||||
expect(plan.yearlyPriceEur).toBe(470);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return features for PRO plan', () => {
|
|
||||||
const plan = SubscriptionPlan.pro();
|
|
||||||
expect(plan.features).toContain('Up to 20 users');
|
|
||||||
expect(plan.features).toContain('API access');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getAllPlans', () => {
|
|
||||||
it('should return all 4 plans', () => {
|
|
||||||
const plans = SubscriptionPlan.getAllPlans();
|
|
||||||
|
|
||||||
expect(plans).toHaveLength(4);
|
|
||||||
expect(plans.map(p => p.value)).toEqual(['FREE', 'STARTER', 'PRO', 'ENTERPRISE']);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('equals', () => {
|
|
||||||
it('should return true for same plan', () => {
|
|
||||||
expect(SubscriptionPlan.free().equals(SubscriptionPlan.free())).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false for different plans', () => {
|
|
||||||
expect(SubscriptionPlan.free().equals(SubscriptionPlan.starter())).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('toString', () => {
|
|
||||||
it('should return plan value as string', () => {
|
|
||||||
expect(SubscriptionPlan.free().toString()).toBe('FREE');
|
|
||||||
expect(SubscriptionPlan.starter().toString()).toBe('STARTER');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,203 +0,0 @@
|
|||||||
/**
|
|
||||||
* Subscription Plan Value Object
|
|
||||||
*
|
|
||||||
* Represents the different subscription plans available for organizations.
|
|
||||||
* Each plan has a maximum number of licenses that determine how many users
|
|
||||||
* can be active in an organization.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export type SubscriptionPlanType = 'FREE' | 'STARTER' | 'PRO' | 'ENTERPRISE';
|
|
||||||
|
|
||||||
interface PlanDetails {
|
|
||||||
readonly name: string;
|
|
||||||
readonly maxLicenses: number; // -1 means unlimited
|
|
||||||
readonly monthlyPriceEur: number;
|
|
||||||
readonly yearlyPriceEur: number;
|
|
||||||
readonly features: readonly string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const PLAN_DETAILS: Record<SubscriptionPlanType, PlanDetails> = {
|
|
||||||
FREE: {
|
|
||||||
name: 'Free',
|
|
||||||
maxLicenses: 2,
|
|
||||||
monthlyPriceEur: 0,
|
|
||||||
yearlyPriceEur: 0,
|
|
||||||
features: [
|
|
||||||
'Up to 2 users',
|
|
||||||
'Basic rate search',
|
|
||||||
'Email support',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
STARTER: {
|
|
||||||
name: 'Starter',
|
|
||||||
maxLicenses: 5,
|
|
||||||
monthlyPriceEur: 49,
|
|
||||||
yearlyPriceEur: 470, // ~20% discount
|
|
||||||
features: [
|
|
||||||
'Up to 5 users',
|
|
||||||
'Advanced rate search',
|
|
||||||
'CSV imports',
|
|
||||||
'Priority email support',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
PRO: {
|
|
||||||
name: 'Pro',
|
|
||||||
maxLicenses: 20,
|
|
||||||
monthlyPriceEur: 149,
|
|
||||||
yearlyPriceEur: 1430, // ~20% discount
|
|
||||||
features: [
|
|
||||||
'Up to 20 users',
|
|
||||||
'All Starter features',
|
|
||||||
'API access',
|
|
||||||
'Custom integrations',
|
|
||||||
'Phone support',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
ENTERPRISE: {
|
|
||||||
name: 'Enterprise',
|
|
||||||
maxLicenses: -1, // unlimited
|
|
||||||
monthlyPriceEur: 0, // custom pricing
|
|
||||||
yearlyPriceEur: 0, // custom pricing
|
|
||||||
features: [
|
|
||||||
'Unlimited users',
|
|
||||||
'All Pro features',
|
|
||||||
'Dedicated account manager',
|
|
||||||
'Custom SLA',
|
|
||||||
'On-premise deployment option',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export class SubscriptionPlan {
|
|
||||||
private constructor(private readonly plan: SubscriptionPlanType) {}
|
|
||||||
|
|
||||||
static create(plan: SubscriptionPlanType): SubscriptionPlan {
|
|
||||||
if (!PLAN_DETAILS[plan]) {
|
|
||||||
throw new Error(`Invalid subscription plan: ${plan}`);
|
|
||||||
}
|
|
||||||
return new SubscriptionPlan(plan);
|
|
||||||
}
|
|
||||||
|
|
||||||
static fromString(value: string): SubscriptionPlan {
|
|
||||||
const upperValue = value.toUpperCase() as SubscriptionPlanType;
|
|
||||||
if (!PLAN_DETAILS[upperValue]) {
|
|
||||||
throw new Error(`Invalid subscription plan: ${value}`);
|
|
||||||
}
|
|
||||||
return new SubscriptionPlan(upperValue);
|
|
||||||
}
|
|
||||||
|
|
||||||
static free(): SubscriptionPlan {
|
|
||||||
return new SubscriptionPlan('FREE');
|
|
||||||
}
|
|
||||||
|
|
||||||
static starter(): SubscriptionPlan {
|
|
||||||
return new SubscriptionPlan('STARTER');
|
|
||||||
}
|
|
||||||
|
|
||||||
static pro(): SubscriptionPlan {
|
|
||||||
return new SubscriptionPlan('PRO');
|
|
||||||
}
|
|
||||||
|
|
||||||
static enterprise(): SubscriptionPlan {
|
|
||||||
return new SubscriptionPlan('ENTERPRISE');
|
|
||||||
}
|
|
||||||
|
|
||||||
static getAllPlans(): SubscriptionPlan[] {
|
|
||||||
return ['FREE', 'STARTER', 'PRO', 'ENTERPRISE'].map(
|
|
||||||
(p) => new SubscriptionPlan(p as SubscriptionPlanType),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
get value(): SubscriptionPlanType {
|
|
||||||
return this.plan;
|
|
||||||
}
|
|
||||||
|
|
||||||
get name(): string {
|
|
||||||
return PLAN_DETAILS[this.plan].name;
|
|
||||||
}
|
|
||||||
|
|
||||||
get maxLicenses(): number {
|
|
||||||
return PLAN_DETAILS[this.plan].maxLicenses;
|
|
||||||
}
|
|
||||||
|
|
||||||
get monthlyPriceEur(): number {
|
|
||||||
return PLAN_DETAILS[this.plan].monthlyPriceEur;
|
|
||||||
}
|
|
||||||
|
|
||||||
get yearlyPriceEur(): number {
|
|
||||||
return PLAN_DETAILS[this.plan].yearlyPriceEur;
|
|
||||||
}
|
|
||||||
|
|
||||||
get features(): readonly string[] {
|
|
||||||
return PLAN_DETAILS[this.plan].features;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns true if this plan has unlimited licenses
|
|
||||||
*/
|
|
||||||
isUnlimited(): boolean {
|
|
||||||
return this.maxLicenses === -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns true if this is a paid plan
|
|
||||||
*/
|
|
||||||
isPaid(): boolean {
|
|
||||||
return this.plan !== 'FREE';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns true if this is the free plan
|
|
||||||
*/
|
|
||||||
isFree(): boolean {
|
|
||||||
return this.plan === 'FREE';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a given number of users can be accommodated by this plan
|
|
||||||
*/
|
|
||||||
canAccommodateUsers(userCount: number): boolean {
|
|
||||||
if (this.isUnlimited()) return true;
|
|
||||||
return userCount <= this.maxLicenses;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if upgrade to target plan is allowed
|
|
||||||
*/
|
|
||||||
canUpgradeTo(targetPlan: SubscriptionPlan): boolean {
|
|
||||||
const planOrder: SubscriptionPlanType[] = [
|
|
||||||
'FREE',
|
|
||||||
'STARTER',
|
|
||||||
'PRO',
|
|
||||||
'ENTERPRISE',
|
|
||||||
];
|
|
||||||
const currentIndex = planOrder.indexOf(this.plan);
|
|
||||||
const targetIndex = planOrder.indexOf(targetPlan.value);
|
|
||||||
return targetIndex > currentIndex;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if downgrade to target plan is allowed given current user count
|
|
||||||
*/
|
|
||||||
canDowngradeTo(targetPlan: SubscriptionPlan, currentUserCount: number): boolean {
|
|
||||||
const planOrder: SubscriptionPlanType[] = [
|
|
||||||
'FREE',
|
|
||||||
'STARTER',
|
|
||||||
'PRO',
|
|
||||||
'ENTERPRISE',
|
|
||||||
];
|
|
||||||
const currentIndex = planOrder.indexOf(this.plan);
|
|
||||||
const targetIndex = planOrder.indexOf(targetPlan.value);
|
|
||||||
|
|
||||||
if (targetIndex >= currentIndex) return false; // Not a downgrade
|
|
||||||
return targetPlan.canAccommodateUsers(currentUserCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
equals(other: SubscriptionPlan): boolean {
|
|
||||||
return this.plan === other.plan;
|
|
||||||
}
|
|
||||||
|
|
||||||
toString(): string {
|
|
||||||
return this.plan;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,215 +0,0 @@
|
|||||||
/**
|
|
||||||
* Subscription Status Value Object
|
|
||||||
*
|
|
||||||
* Represents the different statuses a subscription can have.
|
|
||||||
* Follows Stripe subscription lifecycle states.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export type SubscriptionStatusType =
|
|
||||||
| 'ACTIVE'
|
|
||||||
| 'PAST_DUE'
|
|
||||||
| 'CANCELED'
|
|
||||||
| 'INCOMPLETE'
|
|
||||||
| 'INCOMPLETE_EXPIRED'
|
|
||||||
| 'TRIALING'
|
|
||||||
| 'UNPAID'
|
|
||||||
| 'PAUSED';
|
|
||||||
|
|
||||||
interface StatusDetails {
|
|
||||||
readonly label: string;
|
|
||||||
readonly description: string;
|
|
||||||
readonly allowsAccess: boolean;
|
|
||||||
readonly requiresAction: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const STATUS_DETAILS: Record<SubscriptionStatusType, StatusDetails> = {
|
|
||||||
ACTIVE: {
|
|
||||||
label: 'Active',
|
|
||||||
description: 'Subscription is active and fully paid',
|
|
||||||
allowsAccess: true,
|
|
||||||
requiresAction: false,
|
|
||||||
},
|
|
||||||
PAST_DUE: {
|
|
||||||
label: 'Past Due',
|
|
||||||
description: 'Payment failed but subscription still active. Action required.',
|
|
||||||
allowsAccess: true, // Grace period
|
|
||||||
requiresAction: true,
|
|
||||||
},
|
|
||||||
CANCELED: {
|
|
||||||
label: 'Canceled',
|
|
||||||
description: 'Subscription has been canceled',
|
|
||||||
allowsAccess: false,
|
|
||||||
requiresAction: false,
|
|
||||||
},
|
|
||||||
INCOMPLETE: {
|
|
||||||
label: 'Incomplete',
|
|
||||||
description: 'Initial payment failed during subscription creation',
|
|
||||||
allowsAccess: false,
|
|
||||||
requiresAction: true,
|
|
||||||
},
|
|
||||||
INCOMPLETE_EXPIRED: {
|
|
||||||
label: 'Incomplete Expired',
|
|
||||||
description: 'Subscription creation payment window expired',
|
|
||||||
allowsAccess: false,
|
|
||||||
requiresAction: false,
|
|
||||||
},
|
|
||||||
TRIALING: {
|
|
||||||
label: 'Trialing',
|
|
||||||
description: 'Subscription is in trial period',
|
|
||||||
allowsAccess: true,
|
|
||||||
requiresAction: false,
|
|
||||||
},
|
|
||||||
UNPAID: {
|
|
||||||
label: 'Unpaid',
|
|
||||||
description: 'All payment retry attempts have failed',
|
|
||||||
allowsAccess: false,
|
|
||||||
requiresAction: true,
|
|
||||||
},
|
|
||||||
PAUSED: {
|
|
||||||
label: 'Paused',
|
|
||||||
description: 'Subscription has been paused',
|
|
||||||
allowsAccess: false,
|
|
||||||
requiresAction: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Status transitions that are valid
|
|
||||||
const VALID_TRANSITIONS: Record<SubscriptionStatusType, SubscriptionStatusType[]> = {
|
|
||||||
ACTIVE: ['PAST_DUE', 'CANCELED', 'PAUSED'],
|
|
||||||
PAST_DUE: ['ACTIVE', 'CANCELED', 'UNPAID'],
|
|
||||||
CANCELED: [], // Terminal state
|
|
||||||
INCOMPLETE: ['ACTIVE', 'INCOMPLETE_EXPIRED'],
|
|
||||||
INCOMPLETE_EXPIRED: [], // Terminal state
|
|
||||||
TRIALING: ['ACTIVE', 'PAST_DUE', 'CANCELED'],
|
|
||||||
UNPAID: ['ACTIVE', 'CANCELED'],
|
|
||||||
PAUSED: ['ACTIVE', 'CANCELED'],
|
|
||||||
};
|
|
||||||
|
|
||||||
export class SubscriptionStatus {
|
|
||||||
private constructor(private readonly status: SubscriptionStatusType) {}
|
|
||||||
|
|
||||||
static create(status: SubscriptionStatusType): SubscriptionStatus {
|
|
||||||
if (!STATUS_DETAILS[status]) {
|
|
||||||
throw new Error(`Invalid subscription status: ${status}`);
|
|
||||||
}
|
|
||||||
return new SubscriptionStatus(status);
|
|
||||||
}
|
|
||||||
|
|
||||||
static fromString(value: string): SubscriptionStatus {
|
|
||||||
const upperValue = value.toUpperCase().replace(/-/g, '_') as SubscriptionStatusType;
|
|
||||||
if (!STATUS_DETAILS[upperValue]) {
|
|
||||||
throw new Error(`Invalid subscription status: ${value}`);
|
|
||||||
}
|
|
||||||
return new SubscriptionStatus(upperValue);
|
|
||||||
}
|
|
||||||
|
|
||||||
static fromStripeStatus(stripeStatus: string): SubscriptionStatus {
|
|
||||||
// Map Stripe status to our internal status
|
|
||||||
const mapping: Record<string, SubscriptionStatusType> = {
|
|
||||||
active: 'ACTIVE',
|
|
||||||
past_due: 'PAST_DUE',
|
|
||||||
canceled: 'CANCELED',
|
|
||||||
incomplete: 'INCOMPLETE',
|
|
||||||
incomplete_expired: 'INCOMPLETE_EXPIRED',
|
|
||||||
trialing: 'TRIALING',
|
|
||||||
unpaid: 'UNPAID',
|
|
||||||
paused: 'PAUSED',
|
|
||||||
};
|
|
||||||
|
|
||||||
const mappedStatus = mapping[stripeStatus.toLowerCase()];
|
|
||||||
if (!mappedStatus) {
|
|
||||||
throw new Error(`Unknown Stripe subscription status: ${stripeStatus}`);
|
|
||||||
}
|
|
||||||
return new SubscriptionStatus(mappedStatus);
|
|
||||||
}
|
|
||||||
|
|
||||||
static active(): SubscriptionStatus {
|
|
||||||
return new SubscriptionStatus('ACTIVE');
|
|
||||||
}
|
|
||||||
|
|
||||||
static canceled(): SubscriptionStatus {
|
|
||||||
return new SubscriptionStatus('CANCELED');
|
|
||||||
}
|
|
||||||
|
|
||||||
static pastDue(): SubscriptionStatus {
|
|
||||||
return new SubscriptionStatus('PAST_DUE');
|
|
||||||
}
|
|
||||||
|
|
||||||
static trialing(): SubscriptionStatus {
|
|
||||||
return new SubscriptionStatus('TRIALING');
|
|
||||||
}
|
|
||||||
|
|
||||||
get value(): SubscriptionStatusType {
|
|
||||||
return this.status;
|
|
||||||
}
|
|
||||||
|
|
||||||
get label(): string {
|
|
||||||
return STATUS_DETAILS[this.status].label;
|
|
||||||
}
|
|
||||||
|
|
||||||
get description(): string {
|
|
||||||
return STATUS_DETAILS[this.status].description;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns true if this status allows access to the platform
|
|
||||||
*/
|
|
||||||
allowsAccess(): boolean {
|
|
||||||
return STATUS_DETAILS[this.status].allowsAccess;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns true if this status requires user action (e.g., update payment method)
|
|
||||||
*/
|
|
||||||
requiresAction(): boolean {
|
|
||||||
return STATUS_DETAILS[this.status].requiresAction;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns true if this is a terminal state (cannot transition out)
|
|
||||||
*/
|
|
||||||
isTerminal(): boolean {
|
|
||||||
return VALID_TRANSITIONS[this.status].length === 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns true if the subscription is in good standing
|
|
||||||
*/
|
|
||||||
isInGoodStanding(): boolean {
|
|
||||||
return this.status === 'ACTIVE' || this.status === 'TRIALING';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if transition to new status is valid
|
|
||||||
*/
|
|
||||||
canTransitionTo(newStatus: SubscriptionStatus): boolean {
|
|
||||||
return VALID_TRANSITIONS[this.status].includes(newStatus.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Transition to new status if valid
|
|
||||||
*/
|
|
||||||
transitionTo(newStatus: SubscriptionStatus): SubscriptionStatus {
|
|
||||||
if (!this.canTransitionTo(newStatus)) {
|
|
||||||
throw new Error(
|
|
||||||
`Invalid status transition from ${this.status} to ${newStatus.value}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return newStatus;
|
|
||||||
}
|
|
||||||
|
|
||||||
equals(other: SubscriptionStatus): boolean {
|
|
||||||
return this.status === other.status;
|
|
||||||
}
|
|
||||||
|
|
||||||
toString(): string {
|
|
||||||
return this.status;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert to Stripe-compatible status string
|
|
||||||
*/
|
|
||||||
toStripeStatus(): string {
|
|
||||||
return this.status.toLowerCase().replace(/_/g, '-');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -10,5 +10,3 @@ export * from './carrier.orm-entity';
|
|||||||
export * from './port.orm-entity';
|
export * from './port.orm-entity';
|
||||||
export * from './rate-quote.orm-entity';
|
export * from './rate-quote.orm-entity';
|
||||||
export * from './csv-rate-config.orm-entity';
|
export * from './csv-rate-config.orm-entity';
|
||||||
export * from './subscription.orm-entity';
|
|
||||||
export * from './license.orm-entity';
|
|
||||||
|
|||||||
@ -1,60 +0,0 @@
|
|||||||
/**
|
|
||||||
* License ORM Entity (Infrastructure Layer)
|
|
||||||
*
|
|
||||||
* TypeORM entity for license persistence.
|
|
||||||
* Represents user licenses linked to subscriptions.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {
|
|
||||||
Entity,
|
|
||||||
Column,
|
|
||||||
PrimaryGeneratedColumn,
|
|
||||||
ManyToOne,
|
|
||||||
JoinColumn,
|
|
||||||
Index,
|
|
||||||
} from 'typeorm';
|
|
||||||
import { SubscriptionOrmEntity } from './subscription.orm-entity';
|
|
||||||
import { UserOrmEntity } from './user.orm-entity';
|
|
||||||
|
|
||||||
export type LicenseStatusOrmType = 'ACTIVE' | 'REVOKED';
|
|
||||||
|
|
||||||
@Entity('licenses')
|
|
||||||
@Index('idx_licenses_subscription_id', ['subscriptionId'])
|
|
||||||
@Index('idx_licenses_user_id', ['userId'])
|
|
||||||
@Index('idx_licenses_status', ['status'])
|
|
||||||
@Index('idx_licenses_subscription_status', ['subscriptionId', 'status'])
|
|
||||||
export class LicenseOrmEntity {
|
|
||||||
@PrimaryGeneratedColumn('uuid')
|
|
||||||
id: string;
|
|
||||||
|
|
||||||
@Column({ name: 'subscription_id', type: 'uuid' })
|
|
||||||
subscriptionId: string;
|
|
||||||
|
|
||||||
@ManyToOne(() => SubscriptionOrmEntity, (subscription) => subscription.licenses, {
|
|
||||||
onDelete: 'CASCADE',
|
|
||||||
})
|
|
||||||
@JoinColumn({ name: 'subscription_id' })
|
|
||||||
subscription: SubscriptionOrmEntity;
|
|
||||||
|
|
||||||
@Column({ name: 'user_id', type: 'uuid', unique: true })
|
|
||||||
userId: string;
|
|
||||||
|
|
||||||
@ManyToOne(() => UserOrmEntity, { onDelete: 'CASCADE' })
|
|
||||||
@JoinColumn({ name: 'user_id' })
|
|
||||||
user: UserOrmEntity;
|
|
||||||
|
|
||||||
// Status
|
|
||||||
@Column({
|
|
||||||
type: 'enum',
|
|
||||||
enum: ['ACTIVE', 'REVOKED'],
|
|
||||||
default: 'ACTIVE',
|
|
||||||
})
|
|
||||||
status: LicenseStatusOrmType;
|
|
||||||
|
|
||||||
// Timestamps
|
|
||||||
@Column({ name: 'assigned_at', type: 'timestamp', default: () => 'NOW()' })
|
|
||||||
assignedAt: Date;
|
|
||||||
|
|
||||||
@Column({ name: 'revoked_at', type: 'timestamp', nullable: true })
|
|
||||||
revokedAt: Date | null;
|
|
||||||
}
|
|
||||||
@ -1,108 +0,0 @@
|
|||||||
/**
|
|
||||||
* Subscription ORM Entity (Infrastructure Layer)
|
|
||||||
*
|
|
||||||
* TypeORM entity for subscription persistence.
|
|
||||||
* Represents organization subscriptions with plan and Stripe integration.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {
|
|
||||||
Entity,
|
|
||||||
Column,
|
|
||||||
PrimaryGeneratedColumn,
|
|
||||||
CreateDateColumn,
|
|
||||||
UpdateDateColumn,
|
|
||||||
ManyToOne,
|
|
||||||
OneToMany,
|
|
||||||
JoinColumn,
|
|
||||||
Index,
|
|
||||||
} from 'typeorm';
|
|
||||||
import { OrganizationOrmEntity } from './organization.orm-entity';
|
|
||||||
import { LicenseOrmEntity } from './license.orm-entity';
|
|
||||||
|
|
||||||
export type SubscriptionPlanOrmType = 'FREE' | 'STARTER' | 'PRO' | 'ENTERPRISE';
|
|
||||||
|
|
||||||
export type SubscriptionStatusOrmType =
|
|
||||||
| 'ACTIVE'
|
|
||||||
| 'PAST_DUE'
|
|
||||||
| 'CANCELED'
|
|
||||||
| 'INCOMPLETE'
|
|
||||||
| 'INCOMPLETE_EXPIRED'
|
|
||||||
| 'TRIALING'
|
|
||||||
| 'UNPAID'
|
|
||||||
| 'PAUSED';
|
|
||||||
|
|
||||||
@Entity('subscriptions')
|
|
||||||
@Index('idx_subscriptions_organization_id', ['organizationId'])
|
|
||||||
@Index('idx_subscriptions_stripe_customer_id', ['stripeCustomerId'])
|
|
||||||
@Index('idx_subscriptions_stripe_subscription_id', ['stripeSubscriptionId'])
|
|
||||||
@Index('idx_subscriptions_plan', ['plan'])
|
|
||||||
@Index('idx_subscriptions_status', ['status'])
|
|
||||||
export class SubscriptionOrmEntity {
|
|
||||||
@PrimaryGeneratedColumn('uuid')
|
|
||||||
id: string;
|
|
||||||
|
|
||||||
@Column({ name: 'organization_id', type: 'uuid', unique: true })
|
|
||||||
organizationId: string;
|
|
||||||
|
|
||||||
@ManyToOne(() => OrganizationOrmEntity, { onDelete: 'CASCADE' })
|
|
||||||
@JoinColumn({ name: 'organization_id' })
|
|
||||||
organization: OrganizationOrmEntity;
|
|
||||||
|
|
||||||
// Plan information
|
|
||||||
@Column({
|
|
||||||
type: 'enum',
|
|
||||||
enum: ['FREE', 'STARTER', 'PRO', 'ENTERPRISE'],
|
|
||||||
default: 'FREE',
|
|
||||||
})
|
|
||||||
plan: SubscriptionPlanOrmType;
|
|
||||||
|
|
||||||
@Column({
|
|
||||||
type: 'enum',
|
|
||||||
enum: [
|
|
||||||
'ACTIVE',
|
|
||||||
'PAST_DUE',
|
|
||||||
'CANCELED',
|
|
||||||
'INCOMPLETE',
|
|
||||||
'INCOMPLETE_EXPIRED',
|
|
||||||
'TRIALING',
|
|
||||||
'UNPAID',
|
|
||||||
'PAUSED',
|
|
||||||
],
|
|
||||||
default: 'ACTIVE',
|
|
||||||
})
|
|
||||||
status: SubscriptionStatusOrmType;
|
|
||||||
|
|
||||||
// Stripe integration
|
|
||||||
@Column({ name: 'stripe_customer_id', type: 'varchar', length: 255, nullable: true })
|
|
||||||
stripeCustomerId: string | null;
|
|
||||||
|
|
||||||
@Column({
|
|
||||||
name: 'stripe_subscription_id',
|
|
||||||
type: 'varchar',
|
|
||||||
length: 255,
|
|
||||||
nullable: true,
|
|
||||||
unique: true,
|
|
||||||
})
|
|
||||||
stripeSubscriptionId: string | null;
|
|
||||||
|
|
||||||
// Billing period
|
|
||||||
@Column({ name: 'current_period_start', type: 'timestamp', nullable: true })
|
|
||||||
currentPeriodStart: Date | null;
|
|
||||||
|
|
||||||
@Column({ name: 'current_period_end', type: 'timestamp', nullable: true })
|
|
||||||
currentPeriodEnd: Date | null;
|
|
||||||
|
|
||||||
@Column({ name: 'cancel_at_period_end', type: 'boolean', default: false })
|
|
||||||
cancelAtPeriodEnd: boolean;
|
|
||||||
|
|
||||||
// Timestamps
|
|
||||||
@CreateDateColumn({ name: 'created_at' })
|
|
||||||
createdAt: Date;
|
|
||||||
|
|
||||||
@UpdateDateColumn({ name: 'updated_at' })
|
|
||||||
updatedAt: Date;
|
|
||||||
|
|
||||||
// Relations
|
|
||||||
@OneToMany(() => LicenseOrmEntity, (license) => license.subscription)
|
|
||||||
licenses: LicenseOrmEntity[];
|
|
||||||
}
|
|
||||||
@ -14,12 +14,9 @@ import { CsvBookingOrmEntity } from '../entities/csv-booking.orm-entity';
|
|||||||
export class CsvBookingMapper {
|
export class CsvBookingMapper {
|
||||||
/**
|
/**
|
||||||
* Map ORM entity to domain entity
|
* Map ORM entity to domain entity
|
||||||
*
|
|
||||||
* Uses fromPersistence to avoid validation errors when loading legacy data
|
|
||||||
* that might have empty documents array
|
|
||||||
*/
|
*/
|
||||||
static toDomain(ormEntity: CsvBookingOrmEntity): CsvBooking {
|
static toDomain(ormEntity: CsvBookingOrmEntity): CsvBooking {
|
||||||
return CsvBooking.fromPersistence(
|
return new CsvBooking(
|
||||||
ormEntity.id,
|
ormEntity.id,
|
||||||
ormEntity.userId,
|
ormEntity.userId,
|
||||||
ormEntity.organizationId,
|
ormEntity.organizationId,
|
||||||
|
|||||||
@ -9,5 +9,3 @@ export * from './user-orm.mapper';
|
|||||||
export * from './carrier-orm.mapper';
|
export * from './carrier-orm.mapper';
|
||||||
export * from './port-orm.mapper';
|
export * from './port-orm.mapper';
|
||||||
export * from './rate-quote-orm.mapper';
|
export * from './rate-quote-orm.mapper';
|
||||||
export * from './subscription-orm.mapper';
|
|
||||||
export * from './license-orm.mapper';
|
|
||||||
|
|||||||
@ -1,48 +0,0 @@
|
|||||||
/**
|
|
||||||
* License ORM Mapper
|
|
||||||
*
|
|
||||||
* Maps between License domain entity and LicenseOrmEntity
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { License } from '@domain/entities/license.entity';
|
|
||||||
import { LicenseOrmEntity } from '../entities/license.orm-entity';
|
|
||||||
|
|
||||||
export class LicenseOrmMapper {
|
|
||||||
/**
|
|
||||||
* Map domain entity to ORM entity
|
|
||||||
*/
|
|
||||||
static toOrm(domain: License): LicenseOrmEntity {
|
|
||||||
const orm = new LicenseOrmEntity();
|
|
||||||
const props = domain.toObject();
|
|
||||||
|
|
||||||
orm.id = props.id;
|
|
||||||
orm.subscriptionId = props.subscriptionId;
|
|
||||||
orm.userId = props.userId;
|
|
||||||
orm.status = props.status;
|
|
||||||
orm.assignedAt = props.assignedAt;
|
|
||||||
orm.revokedAt = props.revokedAt;
|
|
||||||
|
|
||||||
return orm;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Map ORM entity to domain entity
|
|
||||||
*/
|
|
||||||
static toDomain(orm: LicenseOrmEntity): License {
|
|
||||||
return License.fromPersistence({
|
|
||||||
id: orm.id,
|
|
||||||
subscriptionId: orm.subscriptionId,
|
|
||||||
userId: orm.userId,
|
|
||||||
status: orm.status,
|
|
||||||
assignedAt: orm.assignedAt,
|
|
||||||
revokedAt: orm.revokedAt,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Map array of ORM entities to domain entities
|
|
||||||
*/
|
|
||||||
static toDomainMany(orms: LicenseOrmEntity[]): License[] {
|
|
||||||
return orms.map((orm) => this.toDomain(orm));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,58 +0,0 @@
|
|||||||
/**
|
|
||||||
* Subscription ORM Mapper
|
|
||||||
*
|
|
||||||
* Maps between Subscription domain entity and SubscriptionOrmEntity
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Subscription } from '@domain/entities/subscription.entity';
|
|
||||||
import { SubscriptionOrmEntity } from '../entities/subscription.orm-entity';
|
|
||||||
|
|
||||||
export class SubscriptionOrmMapper {
|
|
||||||
/**
|
|
||||||
* Map domain entity to ORM entity
|
|
||||||
*/
|
|
||||||
static toOrm(domain: Subscription): SubscriptionOrmEntity {
|
|
||||||
const orm = new SubscriptionOrmEntity();
|
|
||||||
const props = domain.toObject();
|
|
||||||
|
|
||||||
orm.id = props.id;
|
|
||||||
orm.organizationId = props.organizationId;
|
|
||||||
orm.plan = props.plan;
|
|
||||||
orm.status = props.status;
|
|
||||||
orm.stripeCustomerId = props.stripeCustomerId;
|
|
||||||
orm.stripeSubscriptionId = props.stripeSubscriptionId;
|
|
||||||
orm.currentPeriodStart = props.currentPeriodStart;
|
|
||||||
orm.currentPeriodEnd = props.currentPeriodEnd;
|
|
||||||
orm.cancelAtPeriodEnd = props.cancelAtPeriodEnd;
|
|
||||||
orm.createdAt = props.createdAt;
|
|
||||||
orm.updatedAt = props.updatedAt;
|
|
||||||
|
|
||||||
return orm;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Map ORM entity to domain entity
|
|
||||||
*/
|
|
||||||
static toDomain(orm: SubscriptionOrmEntity): Subscription {
|
|
||||||
return Subscription.fromPersistence({
|
|
||||||
id: orm.id,
|
|
||||||
organizationId: orm.organizationId,
|
|
||||||
plan: orm.plan,
|
|
||||||
status: orm.status,
|
|
||||||
stripeCustomerId: orm.stripeCustomerId,
|
|
||||||
stripeSubscriptionId: orm.stripeSubscriptionId,
|
|
||||||
currentPeriodStart: orm.currentPeriodStart,
|
|
||||||
currentPeriodEnd: orm.currentPeriodEnd,
|
|
||||||
cancelAtPeriodEnd: orm.cancelAtPeriodEnd,
|
|
||||||
createdAt: orm.createdAt,
|
|
||||||
updatedAt: orm.updatedAt,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Map array of ORM entities to domain entities
|
|
||||||
*/
|
|
||||||
static toDomainMany(orms: SubscriptionOrmEntity[]): Subscription[] {
|
|
||||||
return orms.map((orm) => this.toDomain(orm));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,98 +0,0 @@
|
|||||||
/**
|
|
||||||
* Migration: Create Subscriptions Table
|
|
||||||
*
|
|
||||||
* This table stores organization subscription information including
|
|
||||||
* plan, status, and Stripe integration data.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
|
||||||
|
|
||||||
export class CreateSubscriptions1738000000001 implements MigrationInterface {
|
|
||||||
name = 'CreateSubscriptions1738000000001';
|
|
||||||
|
|
||||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
|
||||||
// Create subscription_plan enum
|
|
||||||
await queryRunner.query(`
|
|
||||||
CREATE TYPE "subscription_plan_enum" AS ENUM ('FREE', 'STARTER', 'PRO', 'ENTERPRISE')
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Create subscription_status enum
|
|
||||||
await queryRunner.query(`
|
|
||||||
CREATE TYPE "subscription_status_enum" AS ENUM (
|
|
||||||
'ACTIVE',
|
|
||||||
'PAST_DUE',
|
|
||||||
'CANCELED',
|
|
||||||
'INCOMPLETE',
|
|
||||||
'INCOMPLETE_EXPIRED',
|
|
||||||
'TRIALING',
|
|
||||||
'UNPAID',
|
|
||||||
'PAUSED'
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Create subscriptions table
|
|
||||||
await queryRunner.query(`
|
|
||||||
CREATE TABLE "subscriptions" (
|
|
||||||
"id" UUID NOT NULL DEFAULT uuid_generate_v4(),
|
|
||||||
"organization_id" UUID NOT NULL,
|
|
||||||
|
|
||||||
-- Plan information
|
|
||||||
"plan" subscription_plan_enum NOT NULL DEFAULT 'FREE',
|
|
||||||
"status" subscription_status_enum NOT NULL DEFAULT 'ACTIVE',
|
|
||||||
|
|
||||||
-- Stripe integration
|
|
||||||
"stripe_customer_id" VARCHAR(255) NULL,
|
|
||||||
"stripe_subscription_id" VARCHAR(255) NULL,
|
|
||||||
|
|
||||||
-- Billing period
|
|
||||||
"current_period_start" TIMESTAMP NULL,
|
|
||||||
"current_period_end" TIMESTAMP NULL,
|
|
||||||
"cancel_at_period_end" BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
|
|
||||||
-- Timestamps
|
|
||||||
"created_at" TIMESTAMP NOT NULL DEFAULT NOW(),
|
|
||||||
"updated_at" TIMESTAMP NOT NULL DEFAULT NOW(),
|
|
||||||
|
|
||||||
CONSTRAINT "pk_subscriptions" PRIMARY KEY ("id"),
|
|
||||||
CONSTRAINT "uq_subscriptions_organization_id" UNIQUE ("organization_id"),
|
|
||||||
CONSTRAINT "uq_subscriptions_stripe_subscription_id" UNIQUE ("stripe_subscription_id"),
|
|
||||||
CONSTRAINT "fk_subscriptions_organization" FOREIGN KEY ("organization_id")
|
|
||||||
REFERENCES "organizations"("id") ON DELETE CASCADE
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Create indexes
|
|
||||||
await queryRunner.query(`
|
|
||||||
CREATE INDEX "idx_subscriptions_organization_id" ON "subscriptions" ("organization_id")
|
|
||||||
`);
|
|
||||||
await queryRunner.query(`
|
|
||||||
CREATE INDEX "idx_subscriptions_stripe_customer_id" ON "subscriptions" ("stripe_customer_id")
|
|
||||||
`);
|
|
||||||
await queryRunner.query(`
|
|
||||||
CREATE INDEX "idx_subscriptions_stripe_subscription_id" ON "subscriptions" ("stripe_subscription_id")
|
|
||||||
`);
|
|
||||||
await queryRunner.query(`
|
|
||||||
CREATE INDEX "idx_subscriptions_plan" ON "subscriptions" ("plan")
|
|
||||||
`);
|
|
||||||
await queryRunner.query(`
|
|
||||||
CREATE INDEX "idx_subscriptions_status" ON "subscriptions" ("status")
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Add comments
|
|
||||||
await queryRunner.query(`
|
|
||||||
COMMENT ON TABLE "subscriptions" IS 'Organization subscriptions for licensing system'
|
|
||||||
`);
|
|
||||||
await queryRunner.query(`
|
|
||||||
COMMENT ON COLUMN "subscriptions"."plan" IS 'Subscription plan: FREE (2 users), STARTER (5), PRO (20), ENTERPRISE (unlimited)'
|
|
||||||
`);
|
|
||||||
await queryRunner.query(`
|
|
||||||
COMMENT ON COLUMN "subscriptions"."cancel_at_period_end" IS 'If true, subscription will be canceled at the end of current period'
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
|
||||||
await queryRunner.query(`DROP TABLE IF EXISTS "subscriptions" CASCADE`);
|
|
||||||
await queryRunner.query(`DROP TYPE IF EXISTS "subscription_status_enum"`);
|
|
||||||
await queryRunner.query(`DROP TYPE IF EXISTS "subscription_plan_enum"`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,72 +0,0 @@
|
|||||||
/**
|
|
||||||
* Migration: Create Licenses Table
|
|
||||||
*
|
|
||||||
* This table stores user licenses linked to subscriptions.
|
|
||||||
* Each active user in an organization consumes one license.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
|
||||||
|
|
||||||
export class CreateLicenses1738000000002 implements MigrationInterface {
|
|
||||||
name = 'CreateLicenses1738000000002';
|
|
||||||
|
|
||||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
|
||||||
// Create license_status enum
|
|
||||||
await queryRunner.query(`
|
|
||||||
CREATE TYPE "license_status_enum" AS ENUM ('ACTIVE', 'REVOKED')
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Create licenses table
|
|
||||||
await queryRunner.query(`
|
|
||||||
CREATE TABLE "licenses" (
|
|
||||||
"id" UUID NOT NULL DEFAULT uuid_generate_v4(),
|
|
||||||
"subscription_id" UUID NOT NULL,
|
|
||||||
"user_id" UUID NOT NULL,
|
|
||||||
|
|
||||||
-- Status
|
|
||||||
"status" license_status_enum NOT NULL DEFAULT 'ACTIVE',
|
|
||||||
|
|
||||||
-- Timestamps
|
|
||||||
"assigned_at" TIMESTAMP NOT NULL DEFAULT NOW(),
|
|
||||||
"revoked_at" TIMESTAMP NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "pk_licenses" PRIMARY KEY ("id"),
|
|
||||||
CONSTRAINT "uq_licenses_user_id" UNIQUE ("user_id"),
|
|
||||||
CONSTRAINT "fk_licenses_subscription" FOREIGN KEY ("subscription_id")
|
|
||||||
REFERENCES "subscriptions"("id") ON DELETE CASCADE,
|
|
||||||
CONSTRAINT "fk_licenses_user" FOREIGN KEY ("user_id")
|
|
||||||
REFERENCES "users"("id") ON DELETE CASCADE
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Create indexes
|
|
||||||
await queryRunner.query(`
|
|
||||||
CREATE INDEX "idx_licenses_subscription_id" ON "licenses" ("subscription_id")
|
|
||||||
`);
|
|
||||||
await queryRunner.query(`
|
|
||||||
CREATE INDEX "idx_licenses_user_id" ON "licenses" ("user_id")
|
|
||||||
`);
|
|
||||||
await queryRunner.query(`
|
|
||||||
CREATE INDEX "idx_licenses_status" ON "licenses" ("status")
|
|
||||||
`);
|
|
||||||
await queryRunner.query(`
|
|
||||||
CREATE INDEX "idx_licenses_subscription_status" ON "licenses" ("subscription_id", "status")
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Add comments
|
|
||||||
await queryRunner.query(`
|
|
||||||
COMMENT ON TABLE "licenses" IS 'User licenses for subscription-based access control'
|
|
||||||
`);
|
|
||||||
await queryRunner.query(`
|
|
||||||
COMMENT ON COLUMN "licenses"."status" IS 'ACTIVE: license in use, REVOKED: license freed up'
|
|
||||||
`);
|
|
||||||
await queryRunner.query(`
|
|
||||||
COMMENT ON COLUMN "licenses"."revoked_at" IS 'Timestamp when license was revoked, NULL if still active'
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
|
||||||
await queryRunner.query(`DROP TABLE IF EXISTS "licenses" CASCADE`);
|
|
||||||
await queryRunner.query(`DROP TYPE IF EXISTS "license_status_enum"`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,75 +0,0 @@
|
|||||||
/**
|
|
||||||
* Migration: Seed FREE Subscriptions for Existing Organizations
|
|
||||||
*
|
|
||||||
* Creates a FREE subscription for all existing organizations that don't have one,
|
|
||||||
* and assigns licenses to all their active users.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
|
||||||
|
|
||||||
export class SeedFreeSubscriptions1738000000003 implements MigrationInterface {
|
|
||||||
name = 'SeedFreeSubscriptions1738000000003';
|
|
||||||
|
|
||||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
|
||||||
// Create FREE subscription for each organization that doesn't have one
|
|
||||||
await queryRunner.query(`
|
|
||||||
INSERT INTO "subscriptions" (
|
|
||||||
"id",
|
|
||||||
"organization_id",
|
|
||||||
"plan",
|
|
||||||
"status",
|
|
||||||
"created_at",
|
|
||||||
"updated_at"
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
uuid_generate_v4(),
|
|
||||||
o.id,
|
|
||||||
'FREE',
|
|
||||||
'ACTIVE',
|
|
||||||
NOW(),
|
|
||||||
NOW()
|
|
||||||
FROM "organizations" o
|
|
||||||
WHERE NOT EXISTS (
|
|
||||||
SELECT 1 FROM "subscriptions" s WHERE s.organization_id = o.id
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Assign licenses to all active users in organizations with subscriptions
|
|
||||||
await queryRunner.query(`
|
|
||||||
INSERT INTO "licenses" (
|
|
||||||
"id",
|
|
||||||
"subscription_id",
|
|
||||||
"user_id",
|
|
||||||
"status",
|
|
||||||
"assigned_at"
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
uuid_generate_v4(),
|
|
||||||
s.id,
|
|
||||||
u.id,
|
|
||||||
'ACTIVE',
|
|
||||||
NOW()
|
|
||||||
FROM "users" u
|
|
||||||
INNER JOIN "subscriptions" s ON s.organization_id = u.organization_id
|
|
||||||
WHERE u.is_active = true
|
|
||||||
AND NOT EXISTS (
|
|
||||||
SELECT 1 FROM "licenses" l WHERE l.user_id = u.id
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
|
||||||
// Remove licenses created by this migration
|
|
||||||
// Note: This is a destructive operation that cannot perfectly reverse
|
|
||||||
// We'll delete all licenses and subscriptions with FREE plan created after a certain point
|
|
||||||
// In practice, you wouldn't typically revert this in production
|
|
||||||
|
|
||||||
await queryRunner.query(`
|
|
||||||
DELETE FROM "licenses"
|
|
||||||
`);
|
|
||||||
|
|
||||||
await queryRunner.query(`
|
|
||||||
DELETE FROM "subscriptions" WHERE "plan" = 'FREE'
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -9,5 +9,3 @@ export * from './typeorm-user.repository';
|
|||||||
export * from './typeorm-carrier.repository';
|
export * from './typeorm-carrier.repository';
|
||||||
export * from './typeorm-port.repository';
|
export * from './typeorm-port.repository';
|
||||||
export * from './typeorm-rate-quote.repository';
|
export * from './typeorm-rate-quote.repository';
|
||||||
export * from './typeorm-subscription.repository';
|
|
||||||
export * from './typeorm-license.repository';
|
|
||||||
|
|||||||
@ -1,90 +0,0 @@
|
|||||||
/**
|
|
||||||
* TypeORM License Repository
|
|
||||||
*
|
|
||||||
* Implements LicenseRepository interface using TypeORM
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
|
||||||
import { Repository } from 'typeorm';
|
|
||||||
import { License } from '@domain/entities/license.entity';
|
|
||||||
import { LicenseRepository } from '@domain/ports/out/license.repository';
|
|
||||||
import { LicenseOrmEntity } from '../entities/license.orm-entity';
|
|
||||||
import { LicenseOrmMapper } from '../mappers/license-orm.mapper';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class TypeOrmLicenseRepository implements LicenseRepository {
|
|
||||||
constructor(
|
|
||||||
@InjectRepository(LicenseOrmEntity)
|
|
||||||
private readonly repository: Repository<LicenseOrmEntity>,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async save(license: License): Promise<License> {
|
|
||||||
const orm = LicenseOrmMapper.toOrm(license);
|
|
||||||
const saved = await this.repository.save(orm);
|
|
||||||
return LicenseOrmMapper.toDomain(saved);
|
|
||||||
}
|
|
||||||
|
|
||||||
async findById(id: string): Promise<License | null> {
|
|
||||||
const orm = await this.repository.findOne({ where: { id } });
|
|
||||||
return orm ? LicenseOrmMapper.toDomain(orm) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async findByUserId(userId: string): Promise<License | null> {
|
|
||||||
const orm = await this.repository.findOne({ where: { userId } });
|
|
||||||
return orm ? LicenseOrmMapper.toDomain(orm) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async findBySubscriptionId(subscriptionId: string): Promise<License[]> {
|
|
||||||
const orms = await this.repository.find({
|
|
||||||
where: { subscriptionId },
|
|
||||||
order: { assignedAt: 'DESC' },
|
|
||||||
});
|
|
||||||
return LicenseOrmMapper.toDomainMany(orms);
|
|
||||||
}
|
|
||||||
|
|
||||||
async findActiveBySubscriptionId(subscriptionId: string): Promise<License[]> {
|
|
||||||
const orms = await this.repository.find({
|
|
||||||
where: { subscriptionId, status: 'ACTIVE' },
|
|
||||||
order: { assignedAt: 'DESC' },
|
|
||||||
});
|
|
||||||
return LicenseOrmMapper.toDomainMany(orms);
|
|
||||||
}
|
|
||||||
|
|
||||||
async countActiveBySubscriptionId(subscriptionId: string): Promise<number> {
|
|
||||||
return this.repository.count({
|
|
||||||
where: { subscriptionId, status: 'ACTIVE' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async countActiveBySubscriptionIdExcludingAdmins(subscriptionId: string): Promise<number> {
|
|
||||||
const result = await this.repository
|
|
||||||
.createQueryBuilder('license')
|
|
||||||
.innerJoin('license.user', 'user')
|
|
||||||
.where('license.subscriptionId = :subscriptionId', { subscriptionId })
|
|
||||||
.andWhere('license.status = :status', { status: 'ACTIVE' })
|
|
||||||
.andWhere('user.role != :adminRole', { adminRole: 'ADMIN' })
|
|
||||||
.getCount();
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
async findActiveBySubscriptionIdExcludingAdmins(subscriptionId: string): Promise<License[]> {
|
|
||||||
const orms = await this.repository
|
|
||||||
.createQueryBuilder('license')
|
|
||||||
.innerJoin('license.user', 'user')
|
|
||||||
.where('license.subscriptionId = :subscriptionId', { subscriptionId })
|
|
||||||
.andWhere('license.status = :status', { status: 'ACTIVE' })
|
|
||||||
.andWhere('user.role != :adminRole', { adminRole: 'ADMIN' })
|
|
||||||
.orderBy('license.assignedAt', 'DESC')
|
|
||||||
.getMany();
|
|
||||||
return LicenseOrmMapper.toDomainMany(orms);
|
|
||||||
}
|
|
||||||
|
|
||||||
async delete(id: string): Promise<void> {
|
|
||||||
await this.repository.delete({ id });
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteBySubscriptionId(subscriptionId: string): Promise<void> {
|
|
||||||
await this.repository.delete({ subscriptionId });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,60 +0,0 @@
|
|||||||
/**
|
|
||||||
* TypeORM Subscription Repository
|
|
||||||
*
|
|
||||||
* Implements SubscriptionRepository interface using TypeORM
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
|
||||||
import { Repository } from 'typeorm';
|
|
||||||
import { Subscription } from '@domain/entities/subscription.entity';
|
|
||||||
import { SubscriptionRepository } from '@domain/ports/out/subscription.repository';
|
|
||||||
import { SubscriptionOrmEntity } from '../entities/subscription.orm-entity';
|
|
||||||
import { SubscriptionOrmMapper } from '../mappers/subscription-orm.mapper';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class TypeOrmSubscriptionRepository implements SubscriptionRepository {
|
|
||||||
constructor(
|
|
||||||
@InjectRepository(SubscriptionOrmEntity)
|
|
||||||
private readonly repository: Repository<SubscriptionOrmEntity>,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async save(subscription: Subscription): Promise<Subscription> {
|
|
||||||
const orm = SubscriptionOrmMapper.toOrm(subscription);
|
|
||||||
const saved = await this.repository.save(orm);
|
|
||||||
return SubscriptionOrmMapper.toDomain(saved);
|
|
||||||
}
|
|
||||||
|
|
||||||
async findById(id: string): Promise<Subscription | null> {
|
|
||||||
const orm = await this.repository.findOne({ where: { id } });
|
|
||||||
return orm ? SubscriptionOrmMapper.toDomain(orm) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async findByOrganizationId(organizationId: string): Promise<Subscription | null> {
|
|
||||||
const orm = await this.repository.findOne({ where: { organizationId } });
|
|
||||||
return orm ? SubscriptionOrmMapper.toDomain(orm) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async findByStripeSubscriptionId(
|
|
||||||
stripeSubscriptionId: string,
|
|
||||||
): Promise<Subscription | null> {
|
|
||||||
const orm = await this.repository.findOne({ where: { stripeSubscriptionId } });
|
|
||||||
return orm ? SubscriptionOrmMapper.toDomain(orm) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async findByStripeCustomerId(stripeCustomerId: string): Promise<Subscription | null> {
|
|
||||||
const orm = await this.repository.findOne({ where: { stripeCustomerId } });
|
|
||||||
return orm ? SubscriptionOrmMapper.toDomain(orm) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async findAll(): Promise<Subscription[]> {
|
|
||||||
const orms = await this.repository.find({
|
|
||||||
order: { createdAt: 'DESC' },
|
|
||||||
});
|
|
||||||
return SubscriptionOrmMapper.toDomainMany(orms);
|
|
||||||
}
|
|
||||||
|
|
||||||
async delete(id: string): Promise<void> {
|
|
||||||
await this.repository.delete({ id });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
/**
|
|
||||||
* Stripe Infrastructure Barrel Export
|
|
||||||
*/
|
|
||||||
|
|
||||||
export * from './stripe.adapter';
|
|
||||||
export * from './stripe.module';
|
|
||||||
@ -1,233 +0,0 @@
|
|||||||
/**
|
|
||||||
* Stripe Adapter
|
|
||||||
*
|
|
||||||
* Implementation of the StripePort interface using the Stripe SDK.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import Stripe from 'stripe';
|
|
||||||
import {
|
|
||||||
StripePort,
|
|
||||||
CreateCheckoutSessionInput,
|
|
||||||
CreateCheckoutSessionOutput,
|
|
||||||
CreatePortalSessionInput,
|
|
||||||
CreatePortalSessionOutput,
|
|
||||||
StripeSubscriptionData,
|
|
||||||
StripeCheckoutSessionData,
|
|
||||||
StripeWebhookEvent,
|
|
||||||
} from '@domain/ports/out/stripe.port';
|
|
||||||
import { SubscriptionPlanType } from '@domain/value-objects/subscription-plan.vo';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class StripeAdapter implements StripePort {
|
|
||||||
private readonly logger = new Logger(StripeAdapter.name);
|
|
||||||
private readonly stripe: Stripe;
|
|
||||||
private readonly webhookSecret: string;
|
|
||||||
private readonly priceIdMap: Map<string, SubscriptionPlanType>;
|
|
||||||
private readonly planPriceMap: Map<string, { monthly: string; yearly: string }>;
|
|
||||||
|
|
||||||
constructor(private readonly configService: ConfigService) {
|
|
||||||
const apiKey = this.configService.get<string>('STRIPE_SECRET_KEY');
|
|
||||||
if (!apiKey) {
|
|
||||||
this.logger.warn('STRIPE_SECRET_KEY not configured - Stripe features will be disabled');
|
|
||||||
}
|
|
||||||
|
|
||||||
this.stripe = new Stripe(apiKey || 'sk_test_placeholder');
|
|
||||||
|
|
||||||
this.webhookSecret = this.configService.get<string>('STRIPE_WEBHOOK_SECRET') || '';
|
|
||||||
|
|
||||||
// Map Stripe price IDs to plans
|
|
||||||
this.priceIdMap = new Map();
|
|
||||||
this.planPriceMap = new Map();
|
|
||||||
|
|
||||||
// Configure plan price IDs from environment
|
|
||||||
const starterMonthly = this.configService.get<string>('STRIPE_STARTER_MONTHLY_PRICE_ID');
|
|
||||||
const starterYearly = this.configService.get<string>('STRIPE_STARTER_YEARLY_PRICE_ID');
|
|
||||||
const proMonthly = this.configService.get<string>('STRIPE_PRO_MONTHLY_PRICE_ID');
|
|
||||||
const proYearly = this.configService.get<string>('STRIPE_PRO_YEARLY_PRICE_ID');
|
|
||||||
const enterpriseMonthly = this.configService.get<string>('STRIPE_ENTERPRISE_MONTHLY_PRICE_ID');
|
|
||||||
const enterpriseYearly = this.configService.get<string>('STRIPE_ENTERPRISE_YEARLY_PRICE_ID');
|
|
||||||
|
|
||||||
if (starterMonthly) this.priceIdMap.set(starterMonthly, 'STARTER');
|
|
||||||
if (starterYearly) this.priceIdMap.set(starterYearly, 'STARTER');
|
|
||||||
if (proMonthly) this.priceIdMap.set(proMonthly, 'PRO');
|
|
||||||
if (proYearly) this.priceIdMap.set(proYearly, 'PRO');
|
|
||||||
if (enterpriseMonthly) this.priceIdMap.set(enterpriseMonthly, 'ENTERPRISE');
|
|
||||||
if (enterpriseYearly) this.priceIdMap.set(enterpriseYearly, 'ENTERPRISE');
|
|
||||||
|
|
||||||
this.planPriceMap.set('STARTER', {
|
|
||||||
monthly: starterMonthly || '',
|
|
||||||
yearly: starterYearly || '',
|
|
||||||
});
|
|
||||||
this.planPriceMap.set('PRO', {
|
|
||||||
monthly: proMonthly || '',
|
|
||||||
yearly: proYearly || '',
|
|
||||||
});
|
|
||||||
this.planPriceMap.set('ENTERPRISE', {
|
|
||||||
monthly: enterpriseMonthly || '',
|
|
||||||
yearly: enterpriseYearly || '',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async createCheckoutSession(
|
|
||||||
input: CreateCheckoutSessionInput,
|
|
||||||
): Promise<CreateCheckoutSessionOutput> {
|
|
||||||
const planPrices = this.planPriceMap.get(input.plan);
|
|
||||||
if (!planPrices) {
|
|
||||||
throw new Error(`No price configuration for plan: ${input.plan}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const priceId = input.billingInterval === 'yearly'
|
|
||||||
? planPrices.yearly
|
|
||||||
: planPrices.monthly;
|
|
||||||
|
|
||||||
if (!priceId) {
|
|
||||||
throw new Error(
|
|
||||||
`No ${input.billingInterval} price configured for plan: ${input.plan}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionParams: Stripe.Checkout.SessionCreateParams = {
|
|
||||||
mode: 'subscription',
|
|
||||||
payment_method_types: ['card'],
|
|
||||||
line_items: [
|
|
||||||
{
|
|
||||||
price: priceId,
|
|
||||||
quantity: 1,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
success_url: input.successUrl,
|
|
||||||
cancel_url: input.cancelUrl,
|
|
||||||
customer_email: input.customerId ? undefined : input.email,
|
|
||||||
customer: input.customerId || undefined,
|
|
||||||
metadata: {
|
|
||||||
organizationId: input.organizationId,
|
|
||||||
organizationName: input.organizationName,
|
|
||||||
plan: input.plan,
|
|
||||||
},
|
|
||||||
subscription_data: {
|
|
||||||
metadata: {
|
|
||||||
organizationId: input.organizationId,
|
|
||||||
plan: input.plan,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
allow_promotion_codes: true,
|
|
||||||
billing_address_collection: 'required',
|
|
||||||
};
|
|
||||||
|
|
||||||
const session = await this.stripe.checkout.sessions.create(sessionParams);
|
|
||||||
|
|
||||||
this.logger.log(
|
|
||||||
`Created checkout session ${session.id} for organization ${input.organizationId}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
sessionId: session.id,
|
|
||||||
sessionUrl: session.url || '',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async createPortalSession(
|
|
||||||
input: CreatePortalSessionInput,
|
|
||||||
): Promise<CreatePortalSessionOutput> {
|
|
||||||
const session = await this.stripe.billingPortal.sessions.create({
|
|
||||||
customer: input.customerId,
|
|
||||||
return_url: input.returnUrl,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.logger.log(`Created portal session for customer ${input.customerId}`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
sessionUrl: session.url,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async getSubscription(subscriptionId: string): Promise<StripeSubscriptionData | null> {
|
|
||||||
try {
|
|
||||||
const subscription = await this.stripe.subscriptions.retrieve(subscriptionId);
|
|
||||||
|
|
||||||
// Get the price ID from the first item
|
|
||||||
const priceId = subscription.items.data[0]?.price.id || '';
|
|
||||||
|
|
||||||
return {
|
|
||||||
subscriptionId: subscription.id,
|
|
||||||
customerId: subscription.customer as string,
|
|
||||||
status: subscription.status,
|
|
||||||
planId: priceId,
|
|
||||||
currentPeriodStart: new Date(subscription.current_period_start * 1000),
|
|
||||||
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
|
|
||||||
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
if ((error as any).code === 'resource_missing') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async getCheckoutSession(sessionId: string): Promise<StripeCheckoutSessionData | null> {
|
|
||||||
try {
|
|
||||||
const session = await this.stripe.checkout.sessions.retrieve(sessionId);
|
|
||||||
|
|
||||||
return {
|
|
||||||
sessionId: session.id,
|
|
||||||
customerId: session.customer as string | null,
|
|
||||||
subscriptionId: session.subscription as string | null,
|
|
||||||
status: session.status || 'unknown',
|
|
||||||
metadata: (session.metadata || {}) as Record<string, string>,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
if ((error as any).code === 'resource_missing') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
this.logger.error(`Failed to retrieve checkout session ${sessionId}:`, error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async cancelSubscriptionAtPeriodEnd(subscriptionId: string): Promise<void> {
|
|
||||||
await this.stripe.subscriptions.update(subscriptionId, {
|
|
||||||
cancel_at_period_end: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.logger.log(`Scheduled subscription ${subscriptionId} for cancellation at period end`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async cancelSubscriptionImmediately(subscriptionId: string): Promise<void> {
|
|
||||||
await this.stripe.subscriptions.cancel(subscriptionId);
|
|
||||||
|
|
||||||
this.logger.log(`Cancelled subscription ${subscriptionId} immediately`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async resumeSubscription(subscriptionId: string): Promise<void> {
|
|
||||||
await this.stripe.subscriptions.update(subscriptionId, {
|
|
||||||
cancel_at_period_end: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.logger.log(`Resumed subscription ${subscriptionId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async constructWebhookEvent(
|
|
||||||
payload: string | Buffer,
|
|
||||||
signature: string,
|
|
||||||
): Promise<StripeWebhookEvent> {
|
|
||||||
const event = this.stripe.webhooks.constructEvent(
|
|
||||||
payload,
|
|
||||||
signature,
|
|
||||||
this.webhookSecret,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: event.type,
|
|
||||||
data: {
|
|
||||||
object: event.data.object as Record<string, unknown>,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
mapPriceIdToPlan(priceId: string): SubscriptionPlanType | null {
|
|
||||||
return this.priceIdMap.get(priceId) || null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
/**
|
|
||||||
* Stripe Module
|
|
||||||
*
|
|
||||||
* NestJS module for Stripe payment integration.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Module } from '@nestjs/common';
|
|
||||||
import { ConfigModule } from '@nestjs/config';
|
|
||||||
import { StripeAdapter } from './stripe.adapter';
|
|
||||||
import { STRIPE_PORT } from '@domain/ports/out/stripe.port';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
imports: [ConfigModule],
|
|
||||||
providers: [
|
|
||||||
StripeAdapter,
|
|
||||||
{
|
|
||||||
provide: STRIPE_PORT,
|
|
||||||
useExisting: StripeAdapter,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
exports: [STRIPE_PORT, StripeAdapter],
|
|
||||||
})
|
|
||||||
export class StripeModule {}
|
|
||||||
@ -11,8 +11,6 @@ import { helmetConfig, corsConfig } from './infrastructure/security/security.con
|
|||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule, {
|
const app = await NestFactory.create(AppModule, {
|
||||||
bufferLogs: true,
|
bufferLogs: true,
|
||||||
// Enable rawBody for Stripe webhooks signature verification
|
|
||||||
rawBody: true,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get config service
|
// Get config service
|
||||||
|
|||||||
@ -1,981 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
||||||
import { listCsvBookings, CsvBookingResponse } from '@/lib/api/bookings';
|
|
||||||
|
|
||||||
interface Document {
|
|
||||||
id: string;
|
|
||||||
fileName: string;
|
|
||||||
filePath: string;
|
|
||||||
type: string;
|
|
||||||
mimeType: string;
|
|
||||||
size: number;
|
|
||||||
uploadedAt?: Date;
|
|
||||||
// Legacy fields for compatibility
|
|
||||||
name?: string;
|
|
||||||
url?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DocumentWithBooking extends Document {
|
|
||||||
bookingId: string;
|
|
||||||
quoteNumber: string;
|
|
||||||
route: string;
|
|
||||||
status: string;
|
|
||||||
carrierName: string;
|
|
||||||
fileType?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function UserDocumentsPage() {
|
|
||||||
const [bookings, setBookings] = useState<CsvBookingResponse[]>([]);
|
|
||||||
const [documents, setDocuments] = useState<DocumentWithBooking[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
|
||||||
const [filterStatus, setFilterStatus] = useState('all');
|
|
||||||
const [filterQuoteNumber, setFilterQuoteNumber] = useState('');
|
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
|
||||||
const [itemsPerPage, setItemsPerPage] = useState(10);
|
|
||||||
|
|
||||||
// Modal state for adding documents
|
|
||||||
const [showAddModal, setShowAddModal] = useState(false);
|
|
||||||
const [selectedBookingId, setSelectedBookingId] = useState<string | null>(null);
|
|
||||||
const [uploadingFiles, setUploadingFiles] = useState(false);
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
// Modal state for replacing documents
|
|
||||||
const [showReplaceModal, setShowReplaceModal] = useState(false);
|
|
||||||
const [documentToReplace, setDocumentToReplace] = useState<DocumentWithBooking | null>(null);
|
|
||||||
const [replacingFile, setReplacingFile] = useState(false);
|
|
||||||
const replaceFileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
// Dropdown menu state
|
|
||||||
const [openDropdownId, setOpenDropdownId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Helper function to get formatted quote number
|
|
||||||
const getQuoteNumber = (booking: CsvBookingResponse): string => {
|
|
||||||
return `#${booking.bookingId || booking.id.slice(0, 8).toUpperCase()}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get file extension and type
|
|
||||||
const getFileType = (fileName: string): string => {
|
|
||||||
const ext = fileName.split('.').pop()?.toLowerCase() || '';
|
|
||||||
const typeMap: Record<string, string> = {
|
|
||||||
pdf: 'PDF',
|
|
||||||
doc: 'Word',
|
|
||||||
docx: 'Word',
|
|
||||||
xls: 'Excel',
|
|
||||||
xlsx: 'Excel',
|
|
||||||
jpg: 'Image',
|
|
||||||
jpeg: 'Image',
|
|
||||||
png: 'Image',
|
|
||||||
gif: 'Image',
|
|
||||||
txt: 'Text',
|
|
||||||
csv: 'CSV',
|
|
||||||
};
|
|
||||||
return typeMap[ext] || ext.toUpperCase();
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchBookingsAndDocuments = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
// Fetch all user's bookings (paginated, get all pages)
|
|
||||||
const response = await listCsvBookings({ page: 1, limit: 1000 });
|
|
||||||
const allBookings = response.bookings || [];
|
|
||||||
setBookings(allBookings);
|
|
||||||
|
|
||||||
// Extract all documents from all bookings
|
|
||||||
const allDocuments: DocumentWithBooking[] = [];
|
|
||||||
|
|
||||||
allBookings.forEach((booking: CsvBookingResponse) => {
|
|
||||||
if (booking.documents && booking.documents.length > 0) {
|
|
||||||
booking.documents.forEach((doc: any, index: number) => {
|
|
||||||
// Use the correct field names from the backend
|
|
||||||
const actualFileName = doc.fileName || doc.name || 'document';
|
|
||||||
const actualFilePath = doc.filePath || doc.url || '';
|
|
||||||
const actualMimeType = doc.mimeType || doc.type || '';
|
|
||||||
|
|
||||||
// Extract clean file type from mimeType or fileName
|
|
||||||
let fileType = '';
|
|
||||||
if (actualMimeType.includes('/')) {
|
|
||||||
const parts = actualMimeType.split('/');
|
|
||||||
fileType = getFileType(parts[1]);
|
|
||||||
} else {
|
|
||||||
fileType = getFileType(actualFileName);
|
|
||||||
}
|
|
||||||
|
|
||||||
allDocuments.push({
|
|
||||||
id: doc.id || `${booking.id}-doc-${index}`,
|
|
||||||
fileName: actualFileName,
|
|
||||||
filePath: actualFilePath,
|
|
||||||
type: doc.type || '',
|
|
||||||
mimeType: actualMimeType,
|
|
||||||
size: doc.size || 0,
|
|
||||||
uploadedAt: doc.uploadedAt,
|
|
||||||
bookingId: booking.id,
|
|
||||||
quoteNumber: getQuoteNumber(booking),
|
|
||||||
route: `${booking.origin || 'N/A'} → ${booking.destination || 'N/A'}`,
|
|
||||||
status: booking.status,
|
|
||||||
carrierName: booking.carrierName || 'N/A',
|
|
||||||
fileType: fileType,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
setDocuments(allDocuments);
|
|
||||||
setError(null);
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(err.message || 'Erreur lors du chargement des documents');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchBookingsAndDocuments();
|
|
||||||
}, [fetchBookingsAndDocuments]);
|
|
||||||
|
|
||||||
// Filter documents
|
|
||||||
const filteredDocuments = documents.filter(doc => {
|
|
||||||
const matchesSearch =
|
|
||||||
searchTerm === '' ||
|
|
||||||
(doc.fileName && doc.fileName.toLowerCase().includes(searchTerm.toLowerCase())) ||
|
|
||||||
(doc.type && doc.type.toLowerCase().includes(searchTerm.toLowerCase())) ||
|
|
||||||
doc.route.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
||||||
doc.carrierName.toLowerCase().includes(searchTerm.toLowerCase());
|
|
||||||
|
|
||||||
const matchesStatus = filterStatus === 'all' || doc.status === filterStatus;
|
|
||||||
|
|
||||||
const matchesQuote =
|
|
||||||
filterQuoteNumber === '' ||
|
|
||||||
doc.quoteNumber.toLowerCase().includes(filterQuoteNumber.toLowerCase());
|
|
||||||
|
|
||||||
return matchesSearch && matchesStatus && matchesQuote;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Pagination
|
|
||||||
const totalPages = Math.ceil(filteredDocuments.length / itemsPerPage);
|
|
||||||
const startIndex = (currentPage - 1) * itemsPerPage;
|
|
||||||
const endIndex = startIndex + itemsPerPage;
|
|
||||||
const paginatedDocuments = filteredDocuments.slice(startIndex, endIndex);
|
|
||||||
|
|
||||||
// Reset to page 1 when filters change
|
|
||||||
useEffect(() => {
|
|
||||||
setCurrentPage(1);
|
|
||||||
}, [searchTerm, filterStatus, filterQuoteNumber]);
|
|
||||||
|
|
||||||
const getDocumentIcon = (type: string) => {
|
|
||||||
const typeLower = type.toLowerCase();
|
|
||||||
const icons: Record<string, string> = {
|
|
||||||
'application/pdf': '📄',
|
|
||||||
'image/jpeg': '🖼️',
|
|
||||||
'image/png': '🖼️',
|
|
||||||
'image/jpg': '🖼️',
|
|
||||||
pdf: '📄',
|
|
||||||
jpeg: '🖼️',
|
|
||||||
jpg: '🖼️',
|
|
||||||
png: '🖼️',
|
|
||||||
gif: '🖼️',
|
|
||||||
image: '🖼️',
|
|
||||||
word: '📝',
|
|
||||||
doc: '📝',
|
|
||||||
docx: '📝',
|
|
||||||
excel: '📊',
|
|
||||||
xls: '📊',
|
|
||||||
xlsx: '📊',
|
|
||||||
csv: '📊',
|
|
||||||
text: '📄',
|
|
||||||
txt: '📄',
|
|
||||||
};
|
|
||||||
return icons[typeLower] || '📎';
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatusColor = (status: string) => {
|
|
||||||
const colors: Record<string, string> = {
|
|
||||||
PENDING: 'bg-yellow-100 text-yellow-800',
|
|
||||||
ACCEPTED: 'bg-green-100 text-green-800',
|
|
||||||
REJECTED: 'bg-red-100 text-red-800',
|
|
||||||
CANCELLED: 'bg-gray-100 text-gray-800',
|
|
||||||
};
|
|
||||||
return colors[status] || 'bg-gray-100 text-gray-800';
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatusLabel = (status: string) => {
|
|
||||||
const labels: Record<string, string> = {
|
|
||||||
PENDING: 'En attente',
|
|
||||||
ACCEPTED: 'Accepté',
|
|
||||||
REJECTED: 'Refusé',
|
|
||||||
CANCELLED: 'Annulé',
|
|
||||||
};
|
|
||||||
return labels[status] || status;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDownload = async (url: string, fileName: string) => {
|
|
||||||
try {
|
|
||||||
// Try direct download first
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.href = url;
|
|
||||||
link.download = fileName;
|
|
||||||
link.target = '_blank';
|
|
||||||
link.setAttribute('download', fileName);
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
document.body.removeChild(link);
|
|
||||||
|
|
||||||
// If direct download doesn't work, try fetch with blob
|
|
||||||
setTimeout(async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(url, {
|
|
||||||
mode: 'cors',
|
|
||||||
credentials: 'include',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const blob = await response.blob();
|
|
||||||
const blobUrl = window.URL.createObjectURL(blob);
|
|
||||||
const link2 = document.createElement('a');
|
|
||||||
link2.href = blobUrl;
|
|
||||||
link2.download = fileName;
|
|
||||||
document.body.appendChild(link2);
|
|
||||||
link2.click();
|
|
||||||
document.body.removeChild(link2);
|
|
||||||
window.URL.revokeObjectURL(blobUrl);
|
|
||||||
} catch (fetchError) {
|
|
||||||
console.error('Fetch download failed:', fetchError);
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error downloading file:', error);
|
|
||||||
alert(
|
|
||||||
`Erreur lors du téléchargement du document: ${error instanceof Error ? error.message : 'Erreur inconnue'}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get unique bookings for add document modal
|
|
||||||
const bookingsWithPendingStatus = bookings.filter(b => b.status === 'PENDING');
|
|
||||||
|
|
||||||
const handleAddDocumentClick = () => {
|
|
||||||
setShowAddModal(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCloseModal = () => {
|
|
||||||
setShowAddModal(false);
|
|
||||||
setSelectedBookingId(null);
|
|
||||||
if (fileInputRef.current) {
|
|
||||||
fileInputRef.current.value = '';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFileUpload = async () => {
|
|
||||||
if (!selectedBookingId || !fileInputRef.current?.files?.length) {
|
|
||||||
alert('Veuillez sélectionner une réservation et au moins un fichier');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setUploadingFiles(true);
|
|
||||||
try {
|
|
||||||
const formData = new FormData();
|
|
||||||
const files = fileInputRef.current.files;
|
|
||||||
for (let i = 0; i < files.length; i++) {
|
|
||||||
formData.append('documents', files[i]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = localStorage.getItem('access_token');
|
|
||||||
const response = await fetch(
|
|
||||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/csv-bookings/${selectedBookingId}/documents`,
|
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
body: formData,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Erreur lors de l\'ajout des documents');
|
|
||||||
}
|
|
||||||
|
|
||||||
alert('Documents ajoutés avec succès!');
|
|
||||||
handleCloseModal();
|
|
||||||
fetchBookingsAndDocuments(); // Refresh the list
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error uploading documents:', error);
|
|
||||||
alert(
|
|
||||||
`Erreur lors de l'ajout des documents: ${error instanceof Error ? error.message : 'Erreur inconnue'}`
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setUploadingFiles(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Toggle dropdown menu
|
|
||||||
const toggleDropdown = (docId: string) => {
|
|
||||||
setOpenDropdownId(openDropdownId === docId ? null : docId);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Close dropdown when clicking outside
|
|
||||||
useEffect(() => {
|
|
||||||
const handleClickOutside = () => {
|
|
||||||
setOpenDropdownId(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (openDropdownId) {
|
|
||||||
document.addEventListener('click', handleClickOutside);
|
|
||||||
return () => document.removeEventListener('click', handleClickOutside);
|
|
||||||
}
|
|
||||||
}, [openDropdownId]);
|
|
||||||
|
|
||||||
// Replace document handlers
|
|
||||||
const handleReplaceClick = (doc: DocumentWithBooking) => {
|
|
||||||
setOpenDropdownId(null);
|
|
||||||
setDocumentToReplace(doc);
|
|
||||||
setShowReplaceModal(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCloseReplaceModal = () => {
|
|
||||||
setShowReplaceModal(false);
|
|
||||||
setDocumentToReplace(null);
|
|
||||||
if (replaceFileInputRef.current) {
|
|
||||||
replaceFileInputRef.current.value = '';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleReplaceDocument = async () => {
|
|
||||||
if (!documentToReplace || !replaceFileInputRef.current?.files?.length) {
|
|
||||||
alert('Veuillez sélectionner un fichier de remplacement');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setReplacingFile(true);
|
|
||||||
try {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('document', replaceFileInputRef.current.files[0]);
|
|
||||||
|
|
||||||
const token = localStorage.getItem('access_token');
|
|
||||||
const response = await fetch(
|
|
||||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/csv-bookings/${documentToReplace.bookingId}/documents/${documentToReplace.id}`,
|
|
||||||
{
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
body: formData,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json().catch(() => ({}));
|
|
||||||
throw new Error(errorData.message || 'Erreur lors du remplacement du document');
|
|
||||||
}
|
|
||||||
|
|
||||||
alert('Document remplacé avec succès!');
|
|
||||||
handleCloseReplaceModal();
|
|
||||||
fetchBookingsAndDocuments(); // Refresh the list
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error replacing document:', error);
|
|
||||||
alert(
|
|
||||||
`Erreur lors du remplacement: ${error instanceof Error ? error.message : 'Erreur inconnue'}`
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setReplacingFile(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
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>
|
|
||||||
<p className="mt-4 text-gray-600">Chargement des documents...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Mes Documents</h1>
|
|
||||||
<p className="mt-1 text-sm text-gray-500">
|
|
||||||
Gérez tous les documents de vos réservations
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={handleAddDocumentClick}
|
|
||||||
disabled={bookingsWithPendingStatus.length === 0}
|
|
||||||
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M12 4v16m8-8H4"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Ajouter un document
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stats */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
|
||||||
<div className="text-sm text-gray-500">Total Documents</div>
|
|
||||||
<div className="text-2xl font-bold text-gray-900">{documents.length}</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
|
||||||
<div className="text-sm text-gray-500">Réservations avec Documents</div>
|
|
||||||
<div className="text-2xl font-bold text-blue-600">
|
|
||||||
{bookings.filter(b => b.documents && b.documents.length > 0).length}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
|
||||||
<div className="text-sm text-gray-500">Documents Filtrés</div>
|
|
||||||
<div className="text-2xl font-bold text-green-600">{filteredDocuments.length}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Filters */}
|
|
||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Recherche</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Nom, type, route, transporteur..."
|
|
||||||
value={searchTerm}
|
|
||||||
onChange={e => setSearchTerm(e.target.value)}
|
|
||||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Numéro de Devis</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Ex: #F2CAD5E1"
|
|
||||||
value={filterQuoteNumber}
|
|
||||||
onChange={e => setFilterQuoteNumber(e.target.value)}
|
|
||||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Statut</label>
|
|
||||||
<select
|
|
||||||
value={filterStatus}
|
|
||||||
onChange={e => setFilterStatus(e.target.value)}
|
|
||||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
|
|
||||||
>
|
|
||||||
<option value="all">Tous les statuts</option>
|
|
||||||
<option value="PENDING">En attente</option>
|
|
||||||
<option value="ACCEPTED">Accepté</option>
|
|
||||||
<option value="REJECTED">Refusé</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Error Message */}
|
|
||||||
{error && (
|
|
||||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Documents Table */}
|
|
||||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
|
||||||
<table className="min-w-full divide-y divide-gray-200">
|
|
||||||
<thead className="bg-gray-50">
|
|
||||||
<tr>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Nom du Document
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Type
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
N° de Devis
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Route
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Transporteur
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Statut
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Actions
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
|
||||||
{paginatedDocuments.length === 0 ? (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={7} className="px-6 py-12 text-center text-gray-500">
|
|
||||||
{documents.length === 0
|
|
||||||
? 'Aucun document trouvé. Ajoutez des documents à vos réservations.'
|
|
||||||
: 'Aucun document ne correspond aux filtres sélectionnés.'}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : (
|
|
||||||
paginatedDocuments.map((doc, index) => (
|
|
||||||
<tr key={`${doc.bookingId}-${doc.id}-${index}`} className="hover:bg-gray-50">
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<div className="text-sm font-medium text-gray-900">{doc.fileName}</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<span className="text-2xl mr-2">
|
|
||||||
{getDocumentIcon(doc.fileType || doc.type)}
|
|
||||||
</span>
|
|
||||||
<div className="text-xs text-gray-500">{doc.fileType || doc.type}</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
|
||||||
<div className="text-sm font-medium text-gray-900">{doc.quoteNumber}</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
|
||||||
<div className="text-sm text-gray-900">{doc.route}</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
|
||||||
<div className="text-sm text-gray-900">{doc.carrierName}</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
|
||||||
<span
|
|
||||||
className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(doc.status)}`}
|
|
||||||
>
|
|
||||||
{getStatusLabel(doc.status)}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-center">
|
|
||||||
<div className="relative inline-block text-left">
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
toggleDropdown(`${doc.bookingId}-${doc.id}`);
|
|
||||||
}}
|
|
||||||
className="inline-flex items-center justify-center w-8 h-8 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-full transition-colors"
|
|
||||||
title="Actions"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className="w-5 h-5"
|
|
||||||
fill="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<circle cx="12" cy="5" r="2" />
|
|
||||||
<circle cx="12" cy="12" r="2" />
|
|
||||||
<circle cx="12" cy="19" r="2" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Dropdown Menu */}
|
|
||||||
{openDropdownId === `${doc.bookingId}-${doc.id}` && (
|
|
||||||
<div
|
|
||||||
className="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-gray-200 z-50"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<div className="py-1">
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setOpenDropdownId(null);
|
|
||||||
handleDownload(doc.filePath || doc.url || '', doc.fileName);
|
|
||||||
}}
|
|
||||||
className="flex items-center w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className="w-4 h-4 mr-3 text-green-600"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Télécharger
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleReplaceClick(doc)}
|
|
||||||
className="flex items-center w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className="w-4 h-4 mr-3 text-blue-600"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Remplacer
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
{/* Pagination Controls */}
|
|
||||||
{filteredDocuments.length > 0 && (
|
|
||||||
<div className="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6">
|
|
||||||
<div className="flex-1 flex justify-between sm:hidden">
|
|
||||||
<button
|
|
||||||
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
|
|
||||||
disabled={currentPage === 1}
|
|
||||||
className="relative 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 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
Précédent
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
|
|
||||||
disabled={currentPage === totalPages}
|
|
||||||
className="ml-3 relative 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 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
Suivant
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-gray-700">
|
|
||||||
Affichage de <span className="font-medium">{startIndex + 1}</span> à{' '}
|
|
||||||
<span className="font-medium">
|
|
||||||
{Math.min(endIndex, filteredDocuments.length)}
|
|
||||||
</span>{' '}
|
|
||||||
sur <span className="font-medium">{filteredDocuments.length}</span> résultats
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<label className="text-sm text-gray-700">Par page:</label>
|
|
||||||
<select
|
|
||||||
value={itemsPerPage}
|
|
||||||
onChange={e => {
|
|
||||||
setItemsPerPage(Number(e.target.value));
|
|
||||||
setCurrentPage(1);
|
|
||||||
}}
|
|
||||||
className="border border-gray-300 rounded-md px-2 py-1 text-sm"
|
|
||||||
>
|
|
||||||
<option value={5}>5</option>
|
|
||||||
<option value={10}>10</option>
|
|
||||||
<option value={25}>25</option>
|
|
||||||
<option value={50}>50</option>
|
|
||||||
<option value={100}>100</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<nav
|
|
||||||
className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px"
|
|
||||||
aria-label="Pagination"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
|
|
||||||
disabled={currentPage === 1}
|
|
||||||
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
<span className="sr-only">Précédent</span>
|
|
||||||
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path
|
|
||||||
fillRule="evenodd"
|
|
||||||
d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z"
|
|
||||||
clipRule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Page numbers */}
|
|
||||||
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
|
||||||
let pageNum;
|
|
||||||
if (totalPages <= 5) {
|
|
||||||
pageNum = i + 1;
|
|
||||||
} else if (currentPage <= 3) {
|
|
||||||
pageNum = i + 1;
|
|
||||||
} else if (currentPage >= totalPages - 2) {
|
|
||||||
pageNum = totalPages - 4 + i;
|
|
||||||
} else {
|
|
||||||
pageNum = currentPage - 2 + i;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={pageNum}
|
|
||||||
onClick={() => setCurrentPage(pageNum)}
|
|
||||||
className={`relative inline-flex items-center px-4 py-2 border text-sm font-medium ${
|
|
||||||
currentPage === pageNum
|
|
||||||
? 'z-10 bg-blue-50 border-blue-500 text-blue-600'
|
|
||||||
: 'bg-white border-gray-300 text-gray-500 hover:bg-gray-50'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{pageNum}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
|
|
||||||
disabled={currentPage === totalPages}
|
|
||||||
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
<span className="sr-only">Suivant</span>
|
|
||||||
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path
|
|
||||||
fillRule="evenodd"
|
|
||||||
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
|
|
||||||
clipRule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Add Document Modal */}
|
|
||||||
{showAddModal && (
|
|
||||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
|
||||||
<div className="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
|
||||||
{/* Background overlay */}
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
|
|
||||||
onClick={handleCloseModal}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Modal panel */}
|
|
||||||
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
|
|
||||||
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
|
||||||
<div className="sm:flex sm:items-start">
|
|
||||||
<div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-blue-100 sm:mx-0 sm:h-10 sm:w-10">
|
|
||||||
<svg
|
|
||||||
className="h-6 w-6 text-blue-600"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M12 4v16m8-8H4"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left flex-1">
|
|
||||||
<h3 className="text-lg leading-6 font-medium text-gray-900">
|
|
||||||
Ajouter un document
|
|
||||||
</h3>
|
|
||||||
<div className="mt-4 space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Sélectionner une réservation (en attente)
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={selectedBookingId || ''}
|
|
||||||
onChange={e => setSelectedBookingId(e.target.value)}
|
|
||||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
|
|
||||||
>
|
|
||||||
<option value="">-- Choisir une réservation --</option>
|
|
||||||
{bookingsWithPendingStatus.map(booking => (
|
|
||||||
<option key={booking.id} value={booking.id}>
|
|
||||||
{getQuoteNumber(booking)} - {booking.origin} → {booking.destination}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Fichiers à ajouter
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
multiple
|
|
||||||
accept=".pdf,.doc,.docx,.xls,.xlsx,.jpg,.jpeg,.png,.gif"
|
|
||||||
className="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100"
|
|
||||||
/>
|
|
||||||
<p className="mt-1 text-xs text-gray-500">
|
|
||||||
Formats acceptés: PDF, Word, Excel, Images (max 10 fichiers)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleFileUpload}
|
|
||||||
disabled={uploadingFiles || !selectedBookingId}
|
|
||||||
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-600 text-base font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:ml-3 sm:w-auto sm:text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
{uploadingFiles ? (
|
|
||||||
<>
|
|
||||||
<svg
|
|
||||||
className="animate-spin -ml-1 mr-2 h-4 w-4 text-white"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<circle
|
|
||||||
className="opacity-25"
|
|
||||||
cx="12"
|
|
||||||
cy="12"
|
|
||||||
r="10"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="4"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
className="opacity-75"
|
|
||||||
fill="currentColor"
|
|
||||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Envoi en cours...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
'Ajouter'
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleCloseModal}
|
|
||||||
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
|
|
||||||
>
|
|
||||||
Annuler
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Replace Document Modal */}
|
|
||||||
{showReplaceModal && documentToReplace && (
|
|
||||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
|
||||||
<div className="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
|
||||||
{/* Background overlay */}
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
|
|
||||||
onClick={handleCloseReplaceModal}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Modal panel */}
|
|
||||||
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
|
|
||||||
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
|
||||||
<div className="sm:flex sm:items-start">
|
|
||||||
<div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-blue-100 sm:mx-0 sm:h-10 sm:w-10">
|
|
||||||
<svg
|
|
||||||
className="h-6 w-6 text-blue-600"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left flex-1">
|
|
||||||
<h3 className="text-lg leading-6 font-medium text-gray-900">
|
|
||||||
Remplacer le document
|
|
||||||
</h3>
|
|
||||||
<div className="mt-4 space-y-4">
|
|
||||||
{/* Current document info */}
|
|
||||||
<div className="bg-gray-50 rounded-lg p-3">
|
|
||||||
<p className="text-sm text-gray-500">Document actuel:</p>
|
|
||||||
<p className="text-sm font-medium text-gray-900 mt-1">
|
|
||||||
{documentToReplace.fileName}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
|
||||||
Réservation: {documentToReplace.quoteNumber} - {documentToReplace.route}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Nouveau fichier
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
ref={replaceFileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept=".pdf,.doc,.docx,.xls,.xlsx,.jpg,.jpeg,.png,.gif"
|
|
||||||
className="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100"
|
|
||||||
/>
|
|
||||||
<p className="mt-1 text-xs text-gray-500">
|
|
||||||
Formats acceptés: PDF, Word, Excel, Images
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleReplaceDocument}
|
|
||||||
disabled={replacingFile}
|
|
||||||
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-600 text-base font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:ml-3 sm:w-auto sm:text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
{replacingFile ? (
|
|
||||||
<>
|
|
||||||
<svg
|
|
||||||
className="animate-spin -ml-1 mr-2 h-4 w-4 text-white"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<circle
|
|
||||||
className="opacity-25"
|
|
||||||
cx="12"
|
|
||||||
cy="12"
|
|
||||||
r="10"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="4"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
className="opacity-75"
|
|
||||||
fill="currentColor"
|
|
||||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Remplacement en cours...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
'Remplacer'
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleCloseReplaceModal}
|
|
||||||
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
|
|
||||||
>
|
|
||||||
Annuler
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -24,9 +24,6 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
|||||||
const navigation = [
|
const navigation = [
|
||||||
{ name: 'Dashboard', href: '/dashboard', icon: '📊' },
|
{ name: 'Dashboard', href: '/dashboard', icon: '📊' },
|
||||||
{ name: 'Bookings', href: '/dashboard/bookings', icon: '📦' },
|
{ name: 'Bookings', href: '/dashboard/bookings', icon: '📦' },
|
||||||
{ name: 'Documents', href: '/dashboard/documents', icon: '📄' },
|
|
||||||
{ name: 'Track & Trace', href: '/dashboard/track-trace', icon: '🔍' },
|
|
||||||
{ name: 'Wiki', href: '/dashboard/wiki', icon: '📚' },
|
|
||||||
{ name: 'My Profile', href: '/dashboard/profile', icon: '👤' },
|
{ name: 'My Profile', href: '/dashboard/profile', icon: '👤' },
|
||||||
{ name: 'Organization', href: '/dashboard/settings/organization', icon: '🏢' },
|
{ name: 'Organization', href: '/dashboard/settings/organization', icon: '🏢' },
|
||||||
// ADMIN and MANAGER only navigation items
|
// ADMIN and MANAGER only navigation items
|
||||||
|
|||||||
@ -1,12 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState, useCallback } from 'react';
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
import { useSearchParams } from 'next/navigation';
|
|
||||||
import { useAuth } from '@/lib/context/auth-context';
|
import { useAuth } from '@/lib/context/auth-context';
|
||||||
import { getOrganization, updateOrganization } from '@/lib/api/organizations';
|
import { getOrganization, updateOrganization } from '@/lib/api/organizations';
|
||||||
import type { OrganizationResponse } from '@/types/api';
|
import type { OrganizationResponse } from '@/types/api';
|
||||||
import SubscriptionTab from '@/components/organization/SubscriptionTab';
|
|
||||||
import LicensesTab from '@/components/organization/LicensesTab';
|
|
||||||
|
|
||||||
interface OrganizationForm {
|
interface OrganizationForm {
|
||||||
name: string;
|
name: string;
|
||||||
@ -20,21 +17,11 @@ interface OrganizationForm {
|
|||||||
address_country: string;
|
address_country: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type TabType = 'information' | 'address' | 'subscription' | 'licenses';
|
type TabType = 'information' | 'address';
|
||||||
|
|
||||||
export default function OrganizationSettingsPage() {
|
export default function OrganizationSettingsPage() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const [activeTab, setActiveTab] = useState<TabType>('information');
|
const [activeTab, setActiveTab] = useState<TabType>('information');
|
||||||
|
|
||||||
// Auto-switch to subscription tab if coming back from Stripe
|
|
||||||
useEffect(() => {
|
|
||||||
const isSuccess = searchParams.get('success') === 'true';
|
|
||||||
const isCanceled = searchParams.get('canceled') === 'true';
|
|
||||||
if (isSuccess || isCanceled) {
|
|
||||||
setActiveTab('subscription');
|
|
||||||
}
|
|
||||||
}, [searchParams]);
|
|
||||||
const [organization, setOrganization] = useState<OrganizationResponse | null>(null);
|
const [organization, setOrganization] = useState<OrganizationResponse | null>(null);
|
||||||
const [formData, setFormData] = useState<OrganizationForm>({
|
const [formData, setFormData] = useState<OrganizationForm>({
|
||||||
name: '',
|
name: '',
|
||||||
@ -165,56 +152,16 @@ export default function OrganizationSettingsPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const tabs = [
|
|
||||||
{
|
|
||||||
id: 'information' as TabType,
|
|
||||||
label: 'Informations',
|
|
||||||
icon: (
|
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'address' as TabType,
|
|
||||||
label: 'Adresse',
|
|
||||||
icon: (
|
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'subscription' as TabType,
|
|
||||||
label: 'Abonnement',
|
|
||||||
icon: (
|
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'licenses' as TabType,
|
|
||||||
label: 'Licences',
|
|
||||||
icon: (
|
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto">
|
<div className="max-w-4xl mx-auto">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<h1 className="text-3xl font-bold text-gray-900">Paramètres de l'organisation</h1>
|
<h1 className="text-3xl font-bold text-gray-900">Paramètres de l'organisation</h1>
|
||||||
<p className="text-gray-600 mt-2">Gérez les informations de votre organisation</p>
|
<p className="text-gray-600 mt-2">Gérez les informations de votre organisation</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Success Message */}
|
{/* Success Message */}
|
||||||
{successMessage && (activeTab === 'information' || activeTab === 'address') && (
|
{successMessage && (
|
||||||
<div className="mb-6 bg-green-50 border border-green-200 rounded-lg p-4">
|
<div className="mb-6 bg-green-50 border border-green-200 rounded-lg p-4">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<svg className="w-5 h-5 text-green-600 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5 text-green-600 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@ -226,7 +173,7 @@ export default function OrganizationSettingsPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Error Message */}
|
{/* Error Message */}
|
||||||
{error && (activeTab === 'information' || activeTab === 'address') && (
|
{error && (
|
||||||
<div className="mb-6 bg-red-50 border border-red-200 rounded-lg p-4">
|
<div className="mb-6 bg-red-50 border border-red-200 rounded-lg p-4">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<svg className="w-5 h-5 text-red-600 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5 text-red-600 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@ -238,13 +185,13 @@ export default function OrganizationSettingsPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Read-only warning for USER role */}
|
{/* Read-only warning for USER role */}
|
||||||
{!canEdit && (activeTab === 'information' || activeTab === 'address') && (
|
{!canEdit && (
|
||||||
<div className="mb-6 bg-blue-50 border border-blue-200 rounded-lg p-4">
|
<div className="mb-6 bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<svg className="w-5 h-5 text-blue-600 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5 text-blue-600 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
<p className="text-blue-800 font-medium">Mode lecture seule - Seuls les administrateurs et managers peuvent modifier l'organisation</p>
|
<p className="text-blue-800 font-medium">Mode lecture seule - Seuls les administrateurs et managers peuvent modifier l'organisation</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -252,23 +199,38 @@ export default function OrganizationSettingsPage() {
|
|||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="bg-white rounded-lg shadow-md">
|
<div className="bg-white rounded-lg shadow-md">
|
||||||
<div className="border-b border-gray-200">
|
<div className="border-b border-gray-200">
|
||||||
<nav className="flex -mb-px overflow-x-auto">
|
<nav className="flex -mb-px">
|
||||||
{tabs.map((tab) => (
|
<button
|
||||||
<button
|
onClick={() => setActiveTab('information')}
|
||||||
key={tab.id}
|
className={`px-6 py-4 text-sm font-medium border-b-2 transition-colors ${
|
||||||
onClick={() => setActiveTab(tab.id)}
|
activeTab === 'information'
|
||||||
className={`flex-shrink-0 px-6 py-4 text-sm font-medium border-b-2 transition-colors ${
|
? 'border-blue-600 text-blue-600'
|
||||||
activeTab === tab.id
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||||
? 'border-blue-600 text-blue-600'
|
}`}
|
||||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
>
|
||||||
}`}
|
<div className="flex items-center space-x-2">
|
||||||
>
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<div className="flex items-center space-x-2">
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
{tab.icon}
|
</svg>
|
||||||
<span>{tab.label}</span>
|
<span>Informations</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
))}
|
<button
|
||||||
|
onClick={() => setActiveTab('address')}
|
||||||
|
className={`px-6 py-4 text-sm font-medium border-b-2 transition-colors ${
|
||||||
|
activeTab === 'address'
|
||||||
|
? 'border-blue-600 text-blue-600'
|
||||||
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
</svg>
|
||||||
|
<span>Adresse</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -296,7 +258,7 @@ export default function OrganizationSettingsPage() {
|
|||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
SIREN
|
SIREN
|
||||||
<span className="ml-2 text-xs text-gray-500">(Système d'Identification du Répertoire des Entreprises)</span>
|
<span className="ml-2 text-xs text-gray-500">(Système d'Identification du Répertoire des Entreprises)</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@ -431,14 +393,10 @@ export default function OrganizationSettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'subscription' && <SubscriptionTab />}
|
|
||||||
|
|
||||||
{activeTab === 'licenses' && <LicensesTab />}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions (only for information and address tabs) */}
|
{/* Actions */}
|
||||||
{canEdit && (activeTab === 'information' || activeTab === 'address') && (
|
{canEdit && (
|
||||||
<div className="bg-gray-50 px-8 py-4 border-t border-gray-200 flex items-center justify-end space-x-4">
|
<div className="bg-gray-50 px-8 py-4 border-t border-gray-200 flex items-center justify-end space-x-4">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@ -1,31 +0,0 @@
|
|||||||
/**
|
|
||||||
* Subscription Management Page
|
|
||||||
*
|
|
||||||
* Redirects to Organization settings with Subscription tab
|
|
||||||
*/
|
|
||||||
|
|
||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect } from 'react';
|
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
|
||||||
|
|
||||||
export default function SubscriptionPage() {
|
|
||||||
const router = useRouter();
|
|
||||||
const searchParams = useSearchParams();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Preserve any query parameters (success, canceled) from Stripe redirects
|
|
||||||
const params = searchParams.toString();
|
|
||||||
const redirectUrl = `/dashboard/settings/organization${params ? `?${params}` : ''}`;
|
|
||||||
router.replace(redirectUrl);
|
|
||||||
}, [router, searchParams]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center min-h-screen">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-4 border-blue-600 mb-4"></div>
|
|
||||||
<p className="text-gray-600">Redirection...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -9,10 +9,9 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { listUsers, updateUser, deleteUser, canInviteUser } from '@/lib/api';
|
import { listUsers, updateUser, deleteUser } from '@/lib/api';
|
||||||
import { createInvitation } from '@/lib/api/invitations';
|
import { createInvitation } from '@/lib/api/invitations';
|
||||||
import { useAuth } from '@/lib/context/auth-context';
|
import { useAuth } from '@/lib/context/auth-context';
|
||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
export default function UsersManagementPage() {
|
export default function UsersManagementPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -35,12 +34,6 @@ export default function UsersManagementPage() {
|
|||||||
queryFn: () => listUsers(),
|
queryFn: () => listUsers(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check license availability
|
|
||||||
const { data: licenseStatus } = useQuery({
|
|
||||||
queryKey: ['canInvite'],
|
|
||||||
queryFn: () => canInviteUser(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const inviteMutation = useMutation({
|
const inviteMutation = useMutation({
|
||||||
mutationFn: (data: typeof inviteForm) => {
|
mutationFn: (data: typeof inviteForm) => {
|
||||||
return createInvitation({
|
return createInvitation({
|
||||||
@ -52,7 +45,6 @@ export default function UsersManagementPage() {
|
|||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['canInvite'] });
|
|
||||||
setSuccess('Invitation sent successfully! The user will receive an email with a registration link.');
|
setSuccess('Invitation sent successfully! The user will receive an email with a registration link.');
|
||||||
setShowInviteModal(false);
|
setShowInviteModal(false);
|
||||||
setInviteForm({
|
setInviteForm({
|
||||||
@ -90,7 +82,6 @@ export default function UsersManagementPage() {
|
|||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['canInvite'] });
|
|
||||||
setSuccess('User status updated successfully');
|
setSuccess('User status updated successfully');
|
||||||
setTimeout(() => setSuccess(''), 3000);
|
setTimeout(() => setSuccess(''), 3000);
|
||||||
},
|
},
|
||||||
@ -104,7 +95,6 @@ export default function UsersManagementPage() {
|
|||||||
mutationFn: (id: string) => deleteUser(id),
|
mutationFn: (id: string) => deleteUser(id),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['canInvite'] });
|
|
||||||
setSuccess('User deleted successfully');
|
setSuccess('User deleted successfully');
|
||||||
setTimeout(() => setSuccess(''), 3000);
|
setTimeout(() => setSuccess(''), 3000);
|
||||||
},
|
},
|
||||||
@ -169,79 +159,19 @@ export default function UsersManagementPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* License Warning */}
|
|
||||||
{licenseStatus && !licenseStatus.canInvite && (
|
|
||||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
|
|
||||||
<div className="flex items-start">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<svg className="h-5 w-5 text-amber-400" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div className="ml-3 flex-1">
|
|
||||||
<h3 className="text-sm font-medium text-amber-800">License limit reached</h3>
|
|
||||||
<p className="mt-1 text-sm text-amber-700">
|
|
||||||
Your organization has used all available licenses ({licenseStatus.usedLicenses}/{licenseStatus.maxLicenses}).
|
|
||||||
Upgrade your subscription to invite more users.
|
|
||||||
</p>
|
|
||||||
<div className="mt-3">
|
|
||||||
<Link
|
|
||||||
href="/dashboard/settings/subscription"
|
|
||||||
className="text-sm font-medium text-amber-800 hover:text-amber-900 underline"
|
|
||||||
>
|
|
||||||
Upgrade Subscription
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* License Usage Info */}
|
|
||||||
{licenseStatus && licenseStatus.canInvite && licenseStatus.availableLicenses <= 2 && licenseStatus.maxLicenses !== -1 && (
|
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<svg className="h-5 w-5 text-blue-400 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
<span className="text-sm text-blue-800">
|
|
||||||
{licenseStatus.availableLicenses} license{licenseStatus.availableLicenses !== 1 ? 's' : ''} remaining ({licenseStatus.usedLicenses}/{licenseStatus.maxLicenses} used)
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Link
|
|
||||||
href="/dashboard/settings/subscription"
|
|
||||||
className="text-sm font-medium text-blue-600 hover:text-blue-800"
|
|
||||||
>
|
|
||||||
Manage Subscription
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900">User Management</h1>
|
<h1 className="text-2xl font-bold text-gray-900">User Management</h1>
|
||||||
<p className="text-sm text-gray-500 mt-1">Manage team members and their permissions</p>
|
<p className="text-sm text-gray-500 mt-1">Manage team members and their permissions</p>
|
||||||
</div>
|
</div>
|
||||||
{licenseStatus?.canInvite ? (
|
<button
|
||||||
<button
|
onClick={() => setShowInviteModal(true)}
|
||||||
onClick={() => setShowInviteModal(true)}
|
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
|
||||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
|
>
|
||||||
>
|
<span className="mr-2">➕</span>
|
||||||
<span className="mr-2">+</span>
|
Invite User
|
||||||
Invite User
|
</button>
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<Link
|
|
||||||
href="/dashboard/settings/subscription"
|
|
||||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-amber-600 hover:bg-amber-700"
|
|
||||||
>
|
|
||||||
<span className="mr-2">+</span>
|
|
||||||
Upgrade to Invite
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{success && (
|
{success && (
|
||||||
@ -389,23 +319,13 @@ export default function UsersManagementPage() {
|
|||||||
<h3 className="mt-2 text-sm font-medium text-gray-900">No users</h3>
|
<h3 className="mt-2 text-sm font-medium text-gray-900">No users</h3>
|
||||||
<p className="mt-1 text-sm text-gray-500">Get started by inviting a team member</p>
|
<p className="mt-1 text-sm text-gray-500">Get started by inviting a team member</p>
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
{licenseStatus?.canInvite ? (
|
<button
|
||||||
<button
|
onClick={() => setShowInviteModal(true)}
|
||||||
onClick={() => setShowInviteModal(true)}
|
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
|
||||||
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
|
>
|
||||||
>
|
<span className="mr-2">➕</span>
|
||||||
<span className="mr-2">+</span>
|
Invite User
|
||||||
Invite User
|
</button>
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<Link
|
|
||||||
href="/dashboard/settings/subscription"
|
|
||||||
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-amber-600 hover:bg-amber-700"
|
|
||||||
>
|
|
||||||
<span className="mr-2">+</span>
|
|
||||||
Upgrade to Invite
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,287 +0,0 @@
|
|||||||
/**
|
|
||||||
* Track & Trace Page
|
|
||||||
*
|
|
||||||
* Allows users to track their shipments by entering tracking numbers
|
|
||||||
* and selecting the carrier. Redirects to carrier's tracking page.
|
|
||||||
*/
|
|
||||||
|
|
||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
|
|
||||||
// Carrier tracking URLs - the tracking number will be appended
|
|
||||||
const carriers = [
|
|
||||||
{
|
|
||||||
id: 'maersk',
|
|
||||||
name: 'Maersk',
|
|
||||||
logo: '🚢',
|
|
||||||
trackingUrl: 'https://www.maersk.com/tracking/',
|
|
||||||
placeholder: 'Ex: MSKU1234567',
|
|
||||||
description: 'Container or B/L number',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'msc',
|
|
||||||
name: 'MSC',
|
|
||||||
logo: '🛳️',
|
|
||||||
trackingUrl: 'https://www.msc.com/track-a-shipment?query=',
|
|
||||||
placeholder: 'Ex: MSCU1234567',
|
|
||||||
description: 'Container, B/L or Booking number',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'cma-cgm',
|
|
||||||
name: 'CMA CGM',
|
|
||||||
logo: '⚓',
|
|
||||||
trackingUrl: 'https://www.cma-cgm.com/ebusiness/tracking/search?SearchBy=Container&Reference=',
|
|
||||||
placeholder: 'Ex: CMAU1234567',
|
|
||||||
description: 'Container or B/L number',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'hapag-lloyd',
|
|
||||||
name: 'Hapag-Lloyd',
|
|
||||||
logo: '🔷',
|
|
||||||
trackingUrl: 'https://www.hapag-lloyd.com/en/online-business/track/track-by-container-solution.html?container=',
|
|
||||||
placeholder: 'Ex: HLCU1234567',
|
|
||||||
description: 'Container number',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'cosco',
|
|
||||||
name: 'COSCO',
|
|
||||||
logo: '🌊',
|
|
||||||
trackingUrl: 'https://elines.coscoshipping.com/ebusiness/cargoTracking?trackingNumber=',
|
|
||||||
placeholder: 'Ex: COSU1234567',
|
|
||||||
description: 'Container or B/L number',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'one',
|
|
||||||
name: 'ONE (Ocean Network Express)',
|
|
||||||
logo: '🟣',
|
|
||||||
trackingUrl: 'https://ecomm.one-line.com/one-ecom/manage-shipment/cargo-tracking?trkNoParam=',
|
|
||||||
placeholder: 'Ex: ONEU1234567',
|
|
||||||
description: 'Container or B/L number',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'evergreen',
|
|
||||||
name: 'Evergreen',
|
|
||||||
logo: '🌲',
|
|
||||||
trackingUrl: 'https://www.shipmentlink.com/servlet/TDB1_CargoTracking.do?BL=',
|
|
||||||
placeholder: 'Ex: EGHU1234567',
|
|
||||||
description: 'Container or B/L number',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'yangming',
|
|
||||||
name: 'Yang Ming',
|
|
||||||
logo: '🟡',
|
|
||||||
trackingUrl: 'https://www.yangming.com/e-service/Track_Trace/track_trace_cargo_tracking.aspx?rdolType=CT&str=',
|
|
||||||
placeholder: 'Ex: YMLU1234567',
|
|
||||||
description: 'Container number',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'zim',
|
|
||||||
name: 'ZIM',
|
|
||||||
logo: '🔵',
|
|
||||||
trackingUrl: 'https://www.zim.com/tools/track-a-shipment?consnumber=',
|
|
||||||
placeholder: 'Ex: ZIMU1234567',
|
|
||||||
description: 'Container or B/L number',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'hmm',
|
|
||||||
name: 'HMM (Hyundai)',
|
|
||||||
logo: '🟠',
|
|
||||||
trackingUrl: 'https://www.hmm21.com/cms/business/ebiz/trackTrace/trackTrace/index.jsp?type=1&number=',
|
|
||||||
placeholder: 'Ex: HDMU1234567',
|
|
||||||
description: 'Container or B/L number',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function TrackTracePage() {
|
|
||||||
const [trackingNumber, setTrackingNumber] = useState('');
|
|
||||||
const [selectedCarrier, setSelectedCarrier] = useState('');
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
|
|
||||||
const handleTrack = () => {
|
|
||||||
// Validation
|
|
||||||
if (!trackingNumber.trim()) {
|
|
||||||
setError('Veuillez entrer un numéro de tracking');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!selectedCarrier) {
|
|
||||||
setError('Veuillez sélectionner un transporteur');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setError('');
|
|
||||||
|
|
||||||
// Find the carrier and build the tracking URL
|
|
||||||
const carrier = carriers.find(c => c.id === selectedCarrier);
|
|
||||||
if (carrier) {
|
|
||||||
const trackingUrl = carrier.trackingUrl + encodeURIComponent(trackingNumber.trim());
|
|
||||||
// Open in new tab
|
|
||||||
window.open(trackingUrl, '_blank', 'noopener,noreferrer');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
handleTrack();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectedCarrierData = carriers.find(c => c.id === selectedCarrier);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="mb-8">
|
|
||||||
<h1 className="text-3xl font-bold text-gray-900">Track & Trace</h1>
|
|
||||||
<p className="mt-2 text-gray-600">
|
|
||||||
Suivez vos expéditions en temps réel. Entrez votre numéro de tracking et sélectionnez le transporteur.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Search Form */}
|
|
||||||
<Card className="bg-white shadow-lg border-blue-100">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-xl flex items-center gap-2">
|
|
||||||
<span className="text-2xl">🔍</span>
|
|
||||||
Rechercher une expédition
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Entrez votre numéro de conteneur, connaissement (B/L) ou référence de booking
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-6">
|
|
||||||
{/* Carrier Selection */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-3">
|
|
||||||
Sélectionnez le transporteur
|
|
||||||
</label>
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-3">
|
|
||||||
{carriers.map(carrier => (
|
|
||||||
<button
|
|
||||||
key={carrier.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedCarrier(carrier.id);
|
|
||||||
setError('');
|
|
||||||
}}
|
|
||||||
className={`flex flex-col items-center justify-center p-3 rounded-lg border-2 transition-all ${
|
|
||||||
selectedCarrier === carrier.id
|
|
||||||
? 'border-blue-500 bg-blue-50 shadow-md'
|
|
||||||
: 'border-gray-200 hover:border-blue-300 hover:bg-gray-50'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className="text-2xl mb-1">{carrier.logo}</span>
|
|
||||||
<span className="text-xs font-medium text-gray-700 text-center">{carrier.name}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tracking Number Input */}
|
|
||||||
<div>
|
|
||||||
<label htmlFor="tracking-number" className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Numéro de tracking
|
|
||||||
</label>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<div className="flex-1">
|
|
||||||
<Input
|
|
||||||
id="tracking-number"
|
|
||||||
type="text"
|
|
||||||
value={trackingNumber}
|
|
||||||
onChange={e => {
|
|
||||||
setTrackingNumber(e.target.value.toUpperCase());
|
|
||||||
setError('');
|
|
||||||
}}
|
|
||||||
onKeyPress={handleKeyPress}
|
|
||||||
placeholder={selectedCarrierData?.placeholder || 'Ex: MSKU1234567'}
|
|
||||||
className="text-lg font-mono border-gray-300 focus:border-blue-500"
|
|
||||||
/>
|
|
||||||
{selectedCarrierData && (
|
|
||||||
<p className="mt-1 text-xs text-gray-500">{selectedCarrierData.description}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
onClick={handleTrack}
|
|
||||||
className="bg-blue-600 hover:bg-blue-700 text-white px-6"
|
|
||||||
>
|
|
||||||
<span className="mr-2">🔍</span>
|
|
||||||
Rechercher
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Error Message */}
|
|
||||||
{error && (
|
|
||||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg">
|
|
||||||
<p className="text-sm text-red-600">{error}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Help Section */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
||||||
<Card className="bg-white">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-lg flex items-center gap-2">
|
|
||||||
<span>📦</span>
|
|
||||||
Numéro de conteneur
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-sm text-gray-600">
|
|
||||||
Format standard: 4 lettres + 7 chiffres (ex: MSKU1234567).
|
|
||||||
Le préfixe indique généralement le propriétaire du conteneur.
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="bg-white">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-lg flex items-center gap-2">
|
|
||||||
<span>📋</span>
|
|
||||||
Connaissement (B/L)
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-sm text-gray-600">
|
|
||||||
Le numéro de Bill of Lading est fourni par le transporteur lors de la confirmation de booking.
|
|
||||||
Format variable selon le carrier.
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="bg-white">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-lg flex items-center gap-2">
|
|
||||||
<span>📝</span>
|
|
||||||
Référence de booking
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-sm text-gray-600">
|
|
||||||
Numéro de réservation attribué par le transporteur lors de la réservation initiale de l'espace sur le navire.
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Info Box */}
|
|
||||||
<div className="p-4 bg-blue-50 rounded-lg border border-blue-100">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<span className="text-xl">💡</span>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-blue-800">Comment fonctionne le suivi ?</p>
|
|
||||||
<p className="text-sm text-blue-700 mt-1">
|
|
||||||
Cette fonctionnalité vous redirige vers le site officiel du transporteur pour obtenir les informations
|
|
||||||
de suivi les plus récentes. Les données affichées proviennent directement du système du transporteur.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,210 +0,0 @@
|
|||||||
/**
|
|
||||||
* Assurance Maritime - Wiki Page
|
|
||||||
*
|
|
||||||
* Protection des marchandises en transit
|
|
||||||
*/
|
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
|
||||||
|
|
||||||
const clausesICC = [
|
|
||||||
{
|
|
||||||
code: 'ICC A',
|
|
||||||
name: 'All Risks',
|
|
||||||
coverage: 'Tous risques',
|
|
||||||
description: 'Couverture la plus complète. Couvre tous les risques de perte ou dommage sauf exclusions spécifiques.',
|
|
||||||
includes: ['Vol', 'Casse', 'Mouille', 'Manquants', 'Chute', 'Écrasement'],
|
|
||||||
excludes: ['Vice propre', 'Emballage insuffisant', 'Guerre', 'Grèves'],
|
|
||||||
recommended: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: 'ICC B',
|
|
||||||
name: 'With Average',
|
|
||||||
coverage: 'Risques majeurs',
|
|
||||||
description: 'Couverture intermédiaire incluant les événements majeurs de transport.',
|
|
||||||
includes: ['Incendie', 'Naufrage', 'Échouement', 'Collision', 'Jet à la mer', 'Avarie commune'],
|
|
||||||
excludes: ['Vol', 'Mouille', 'Manquants (hors avarie commune)', 'Casse isolée'],
|
|
||||||
recommended: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: 'ICC C',
|
|
||||||
name: 'Free of Particular Average',
|
|
||||||
coverage: 'Risques minimaux',
|
|
||||||
description: 'Couverture de base pour les sinistres majeurs uniquement.',
|
|
||||||
includes: ['Incendie', 'Naufrage', 'Échouement', 'Collision', 'Avarie commune'],
|
|
||||||
excludes: ['Vol', 'Mouille', 'Casse', 'Manquants', 'Perte partielle'],
|
|
||||||
recommended: false,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const extensionsGaranties = [
|
|
||||||
{ name: 'Guerre et grèves', description: 'Extension pour couvrir les risques de guerre, grèves, émeutes.' },
|
|
||||||
{ name: 'Magasin à magasin', description: 'Couverture étendue incluant les phases de stockage.' },
|
|
||||||
{ name: 'Frais de réexpédition', description: 'Couvre les frais en cas de changement de destination.' },
|
|
||||||
{ name: 'Pertes financières', description: 'Perte de marge, frais supplémentaires liés au sinistre.' },
|
|
||||||
{ name: 'Transport frigorifique', description: 'Risques spécifiques liés au froid (panne, variation).' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function AssurancePage() {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Header with back link */}
|
|
||||||
<div className="flex items-center gap-4 mb-6">
|
|
||||||
<Link
|
|
||||||
href="/dashboard/wiki"
|
|
||||||
className="flex items-center text-blue-600 hover:text-blue-800 transition-colors"
|
|
||||||
>
|
|
||||||
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
|
||||||
</svg>
|
|
||||||
Retour au Wiki
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Title */}
|
|
||||||
<div className="mb-8">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="text-4xl">🛡️</span>
|
|
||||||
<h1 className="text-3xl font-bold text-gray-900">Assurance Maritime (Cargo Insurance)</h1>
|
|
||||||
</div>
|
|
||||||
<p className="mt-3 text-gray-600 max-w-3xl">
|
|
||||||
L'assurance transport maritime protège les marchandises contre les risques de perte ou de dommage
|
|
||||||
pendant le transit. Elle est régie par les Institute Cargo Clauses (ICC) de l'Institute of London Underwriters.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Why insure */}
|
|
||||||
<Card className="bg-blue-50 border-blue-200">
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<h3 className="font-semibold text-blue-900 mb-3">Pourquoi s'assurer ?</h3>
|
|
||||||
<ul className="list-disc list-inside space-y-2 text-blue-800">
|
|
||||||
<li><strong>Responsabilité limitée du transporteur</strong> : Maximum ~2 DTS/kg (Convention de Bruxelles)</li>
|
|
||||||
<li><strong>Délai de réclamation court</strong> : 3 jours pour les réserves au transporteur</li>
|
|
||||||
<li><strong>Preuves difficiles</strong> : Charge de la preuve souvent sur l'expéditeur</li>
|
|
||||||
<li><strong>Exigence bancaire</strong> : Souvent requise pour les lettres de crédit (CIF, CIP)</li>
|
|
||||||
</ul>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* ICC Clauses */}
|
|
||||||
<div className="mt-8">
|
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-4">📋 Clauses ICC (Institute Cargo Clauses)</h2>
|
|
||||||
<div className="space-y-4">
|
|
||||||
{clausesICC.map((clause) => (
|
|
||||||
<Card key={clause.code} className={`bg-white ${clause.recommended ? 'border-green-300 border-2' : ''}`}>
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle className="flex items-center gap-3">
|
|
||||||
<span className={`px-3 py-1 text-white rounded-md font-mono ${clause.recommended ? 'bg-green-600' : 'bg-gray-600'}`}>
|
|
||||||
{clause.code}
|
|
||||||
</span>
|
|
||||||
<span className="text-lg">{clause.name}</span>
|
|
||||||
{clause.recommended && (
|
|
||||||
<span className="px-2 py-1 text-xs bg-green-100 text-green-700 rounded">Recommandé</span>
|
|
||||||
)}
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-gray-600 mb-4">{clause.description}</p>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<h4 className="font-medium text-green-700 mb-2">✓ Couvert</h4>
|
|
||||||
<ul className="text-sm text-gray-600 space-y-1">
|
|
||||||
{clause.includes.map((item) => (
|
|
||||||
<li key={item} className="flex items-center gap-2">
|
|
||||||
<span className="w-1.5 h-1.5 bg-green-500 rounded-full"></span>
|
|
||||||
{item}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 className="font-medium text-red-700 mb-2">✗ Non couvert</h4>
|
|
||||||
<ul className="text-sm text-gray-600 space-y-1">
|
|
||||||
{clause.excludes.map((item) => (
|
|
||||||
<li key={item} className="flex items-center gap-2">
|
|
||||||
<span className="w-1.5 h-1.5 bg-red-500 rounded-full"></span>
|
|
||||||
{item}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Valeur assurée */}
|
|
||||||
<Card className="mt-8 bg-gray-50">
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<h3 className="font-semibold text-gray-900 mb-3">💰 Calcul de la Valeur Assurée</h3>
|
|
||||||
<div className="bg-white p-4 rounded-lg border">
|
|
||||||
<div className="text-center mb-4">
|
|
||||||
<p className="text-lg font-mono bg-blue-100 text-blue-800 p-3 rounded inline-block">
|
|
||||||
Valeur assurée = (CIF + 10%) × Taux de change
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
|
|
||||||
<div>
|
|
||||||
<h4 className="font-medium">CIF</h4>
|
|
||||||
<p className="text-gray-600">Coût + Assurance + Fret jusqu'au port de destination</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 className="font-medium">+ 10%</h4>
|
|
||||||
<p className="text-gray-600">Majoration standard pour couvrir le profit espéré</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 className="font-medium">Taux de prime</h4>
|
|
||||||
<p className="text-gray-600">0.1% à 1% selon marchandise, trajet, clause</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Extensions */}
|
|
||||||
<div className="mt-8">
|
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-4">➕ Extensions de Garantie</h2>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
{extensionsGaranties.map((ext) => (
|
|
||||||
<Card key={ext.name} className="bg-white">
|
|
||||||
<CardContent className="pt-4">
|
|
||||||
<h4 className="font-medium text-gray-900">{ext.name}</h4>
|
|
||||||
<p className="text-sm text-gray-600 mt-1">{ext.description}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Process */}
|
|
||||||
<Card className="mt-8 bg-gray-50">
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<h3 className="font-semibold text-gray-900 mb-3">📝 En Cas de Sinistre</h3>
|
|
||||||
<ol className="list-decimal list-inside space-y-3 text-gray-700">
|
|
||||||
<li><strong>Constater</strong> : Émettre des réserves précises sur le bon de livraison</li>
|
|
||||||
<li><strong>Préserver</strong> : Ne pas modifier l'état des marchandises (photos, témoins)</li>
|
|
||||||
<li><strong>Notifier</strong> : Informer l'assureur sous 5 jours ouvrés</li>
|
|
||||||
<li><strong>Documenter</strong> : Rassembler tous les documents (B/L, facture, expertise)</li>
|
|
||||||
<li><strong>Réclamer</strong> : Déposer une réclamation formelle avec justificatifs</li>
|
|
||||||
</ol>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Tips */}
|
|
||||||
<Card className="mt-8 bg-amber-50 border-amber-200">
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<h3 className="font-semibold text-amber-900 mb-3">💡 Conseils Pratiques</h3>
|
|
||||||
<ul className="list-disc list-inside space-y-2 text-amber-800">
|
|
||||||
<li>Toujours opter pour ICC A (All Risks) sauf marchandises très résistantes</li>
|
|
||||||
<li>Vérifier les exclusions et souscrire les extensions nécessaires</li>
|
|
||||||
<li>Photographier la marchandise avant expédition</li>
|
|
||||||
<li>Conserver tous les documents originaux</li>
|
|
||||||
<li>Ne jamais signer "reçu conforme" sans avoir vérifié</li>
|
|
||||||
</ul>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,269 +0,0 @@
|
|||||||
/**
|
|
||||||
* Calcul du Fret Maritime - Wiki Page
|
|
||||||
*
|
|
||||||
* Comment sont calculés les coûts de transport
|
|
||||||
*/
|
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
|
||||||
|
|
||||||
const surcharges = [
|
|
||||||
{
|
|
||||||
code: 'BAF',
|
|
||||||
name: 'Bunker Adjustment Factor',
|
|
||||||
description: 'Surcharge carburant liée aux fluctuations du prix du fuel maritime.',
|
|
||||||
variation: 'Mensuelle',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: 'CAF',
|
|
||||||
name: 'Currency Adjustment Factor',
|
|
||||||
description: 'Ajustement monétaire pour compenser les variations de taux de change.',
|
|
||||||
variation: 'Mensuelle',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: 'THC',
|
|
||||||
name: 'Terminal Handling Charges',
|
|
||||||
description: 'Frais de manutention au terminal portuaire (chargement/déchargement).',
|
|
||||||
variation: 'Par port',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: 'ISPS',
|
|
||||||
name: 'International Ship & Port Security',
|
|
||||||
description: 'Surcharge sécurité conforme au code ISPS (post 11 septembre).',
|
|
||||||
variation: 'Fixe',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: 'LSS',
|
|
||||||
name: 'Low Sulphur Surcharge',
|
|
||||||
description: 'Surcharge carburant bas soufre (réglementation IMO 2020).',
|
|
||||||
variation: 'Mensuelle',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: 'PSS',
|
|
||||||
name: 'Peak Season Surcharge',
|
|
||||||
description: 'Surcharge haute saison (généralement août-octobre vers l\'Europe).',
|
|
||||||
variation: 'Saisonnière',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: 'GRI',
|
|
||||||
name: 'General Rate Increase',
|
|
||||||
description: 'Augmentation générale des tarifs, généralement annoncée à l\'avance.',
|
|
||||||
variation: 'Variable',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: 'EBS',
|
|
||||||
name: 'Emergency Bunker Surcharge',
|
|
||||||
description: 'Surcharge d\'urgence en cas de hausse brutale du carburant.',
|
|
||||||
variation: 'Exceptionnelle',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const fraisAdditionnels = [
|
|
||||||
{ name: 'Documentation Fee', description: 'Frais d\'émission du B/L ou autres documents', typical: '35-75 USD' },
|
|
||||||
{ name: 'Seal Fee', description: 'Coût du plomb de sécurité du conteneur', typical: '10-25 USD' },
|
|
||||||
{ name: 'VGM Fee', description: 'Frais de certification du poids vérifié', typical: '25-50 USD' },
|
|
||||||
{ name: 'Container Cleaning', description: 'Nettoyage du conteneur si requis', typical: '50-150 USD' },
|
|
||||||
{ name: 'Customs Clearance', description: 'Frais de dédouanement (honoraires)', typical: '50-200 USD' },
|
|
||||||
{ name: 'Inspection Fee', description: 'Frais d\'inspection (scanner, phytosanitaire)', typical: '50-300 USD' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function CalculFretPage() {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Header with back link */}
|
|
||||||
<div className="flex items-center gap-4 mb-6">
|
|
||||||
<Link
|
|
||||||
href="/dashboard/wiki"
|
|
||||||
className="flex items-center text-blue-600 hover:text-blue-800 transition-colors"
|
|
||||||
>
|
|
||||||
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
|
||||||
</svg>
|
|
||||||
Retour au Wiki
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Title */}
|
|
||||||
<div className="mb-8">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="text-4xl">🧮</span>
|
|
||||||
<h1 className="text-3xl font-bold text-gray-900">Calcul du Fret Maritime</h1>
|
|
||||||
</div>
|
|
||||||
<p className="mt-3 text-gray-600 max-w-3xl">
|
|
||||||
Le coût du transport maritime se compose du fret de base et de nombreuses surcharges.
|
|
||||||
Comprendre ces éléments est essentiel pour bien estimer ses coûts logistiques.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Base Calculation */}
|
|
||||||
<Card className="bg-blue-50 border-blue-200">
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<h3 className="font-semibold text-blue-900 mb-3">📐 Principes de Base</h3>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
<div>
|
|
||||||
<h4 className="font-medium text-blue-800">FCL (Conteneur Complet)</h4>
|
|
||||||
<p className="text-sm text-blue-700 mt-1">
|
|
||||||
Prix forfaitaire par conteneur (20', 40', 40'HC), indépendant du poids (dans les limites).
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-blue-600 mt-2">Ex: 1,500 USD/20' Shanghai → Rotterdam</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 className="font-medium text-blue-800">LCL (Groupage)</h4>
|
|
||||||
<p className="text-sm text-blue-700 mt-1">
|
|
||||||
Prix au mètre cube (CBM) ou à la tonne, selon le plus avantageux pour le transporteur.
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-blue-600 mt-2">Ex: 45 USD/CBM (minimum 1 CBM)</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Weight Calculation */}
|
|
||||||
<Card className="mt-8 bg-gray-50">
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<h3 className="font-semibold text-gray-900 mb-3">⚖️ Poids Taxable (LCL)</h3>
|
|
||||||
<div className="bg-white p-4 rounded-lg border mb-4">
|
|
||||||
<div className="text-center">
|
|
||||||
<p className="text-lg font-mono bg-blue-100 text-blue-800 p-3 rounded inline-block">
|
|
||||||
Poids taxable = MAX (Volume CBM × 1000, Poids brut kg)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
|
||||||
<div className="bg-white p-4 rounded-lg border">
|
|
||||||
<h4 className="font-medium">Exemple 1 : Marchandise légère</h4>
|
|
||||||
<ul className="text-gray-600 mt-2 space-y-1">
|
|
||||||
<li>Volume : 2 CBM</li>
|
|
||||||
<li>Poids : 500 kg</li>
|
|
||||||
<li>Volume équivalent : 2 × 1000 = 2000 kg</li>
|
|
||||||
<li className="font-medium text-blue-700">→ Taxé sur 2 CBM (ou 2 tonnes)</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white p-4 rounded-lg border">
|
|
||||||
<h4 className="font-medium">Exemple 2 : Marchandise lourde</h4>
|
|
||||||
<ul className="text-gray-600 mt-2 space-y-1">
|
|
||||||
<li>Volume : 1 CBM</li>
|
|
||||||
<li>Poids : 2000 kg</li>
|
|
||||||
<li>Volume équivalent : 1 × 1000 = 1000 kg</li>
|
|
||||||
<li className="font-medium text-blue-700">→ Taxé sur 2 tonnes (poids réel)</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-gray-500 mt-3">
|
|
||||||
Ratio standard : 1 CBM = 1 tonne (1000 kg). Certaines compagnies utilisent 1 CBM = 333 kg pour l'aérien.
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Surcharges */}
|
|
||||||
<div className="mt-8">
|
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-4">📋 Surcharges Courantes</h2>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
{surcharges.map((sur) => (
|
|
||||||
<Card key={sur.code} className="bg-white">
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<span className="px-3 py-1 bg-purple-600 text-white rounded-md font-mono text-sm">
|
|
||||||
{sur.code}
|
|
||||||
</span>
|
|
||||||
<span className="text-base">{sur.name}</span>
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-gray-600 text-sm">{sur.description}</p>
|
|
||||||
<p className="text-xs text-gray-500 mt-2">Variation : {sur.variation}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Additional fees */}
|
|
||||||
<div className="mt-8">
|
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-4">💵 Frais Additionnels</h2>
|
|
||||||
<Card className="bg-white">
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b">
|
|
||||||
<th className="text-left py-2 font-medium">Frais</th>
|
|
||||||
<th className="text-left py-2 font-medium">Description</th>
|
|
||||||
<th className="text-right py-2 font-medium">Montant typique</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{fraisAdditionnels.map((frais) => (
|
|
||||||
<tr key={frais.name} className="border-b last:border-0">
|
|
||||||
<td className="py-3 font-medium text-gray-900">{frais.name}</td>
|
|
||||||
<td className="py-3 text-gray-600">{frais.description}</td>
|
|
||||||
<td className="py-3 text-right font-mono text-gray-700">{frais.typical}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Example calculation */}
|
|
||||||
<Card className="mt-8 bg-green-50 border-green-200">
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<h3 className="font-semibold text-green-900 mb-3">📊 Exemple de Devis FCL</h3>
|
|
||||||
<div className="bg-white p-4 rounded-lg border">
|
|
||||||
<p className="text-sm text-gray-600 mb-3">Conteneur 40' Shanghai → Le Havre</p>
|
|
||||||
<div className="space-y-2 font-mono text-sm">
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span>Ocean Freight (base)</span>
|
|
||||||
<span>1,800 USD</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span>BAF</span>
|
|
||||||
<span>450 USD</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span>LSS</span>
|
|
||||||
<span>180 USD</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span>THC Origin</span>
|
|
||||||
<span>150 USD</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span>THC Destination</span>
|
|
||||||
<span>280 EUR</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span>ISPS</span>
|
|
||||||
<span>12 USD</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span>Documentation</span>
|
|
||||||
<span>45 USD</span>
|
|
||||||
</div>
|
|
||||||
<div className="border-t pt-2 flex justify-between font-bold">
|
|
||||||
<span>Total estimé</span>
|
|
||||||
<span>~2,637 USD + 280 EUR</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Tips */}
|
|
||||||
<Card className="mt-8 bg-amber-50 border-amber-200">
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<h3 className="font-semibold text-amber-900 mb-3">💡 Conseils pour Optimiser</h3>
|
|
||||||
<ul className="list-disc list-inside space-y-2 text-amber-800">
|
|
||||||
<li>Demandez des devis "All-in" pour éviter les surprises de surcharges</li>
|
|
||||||
<li>Comparez les transitaires sur le total, pas seulement le fret de base</li>
|
|
||||||
<li>Anticipez la haute saison (septembre-novembre) avec des réservations précoces</li>
|
|
||||||
<li>Optimisez le remplissage des conteneurs pour réduire le coût unitaire</li>
|
|
||||||
<li>Vérifiez les surcharges qui peuvent changer entre devis et facture</li>
|
|
||||||
</ul>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,315 +0,0 @@
|
|||||||
/**
|
|
||||||
* Conteneurs et Types de Cargo - Wiki Page
|
|
||||||
*
|
|
||||||
* Complete guide to container types
|
|
||||||
*/
|
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
|
||||||
|
|
||||||
const containers = [
|
|
||||||
{
|
|
||||||
type: '20\' Standard (20\' DRY)',
|
|
||||||
code: '20DC',
|
|
||||||
dimensions: {
|
|
||||||
external: '6.06m x 2.44m x 2.59m',
|
|
||||||
internal: '5.90m x 2.35m x 2.39m',
|
|
||||||
door: '2.34m x 2.28m',
|
|
||||||
},
|
|
||||||
capacity: {
|
|
||||||
volume: '33 m³',
|
|
||||||
payload: '28,200 kg',
|
|
||||||
tare: '2,300 kg',
|
|
||||||
},
|
|
||||||
usage: 'Marchandises générales sèches',
|
|
||||||
icon: '📦',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: '40\' Standard (40\' DRY)',
|
|
||||||
code: '40DC',
|
|
||||||
dimensions: {
|
|
||||||
external: '12.19m x 2.44m x 2.59m',
|
|
||||||
internal: '12.03m x 2.35m x 2.39m',
|
|
||||||
door: '2.34m x 2.28m',
|
|
||||||
},
|
|
||||||
capacity: {
|
|
||||||
volume: '67 m³',
|
|
||||||
payload: '26,680 kg',
|
|
||||||
tare: '3,800 kg',
|
|
||||||
},
|
|
||||||
usage: 'Marchandises générales, cargo volumineux',
|
|
||||||
icon: '📦',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: '40\' High Cube (40\' HC)',
|
|
||||||
code: '40HC',
|
|
||||||
dimensions: {
|
|
||||||
external: '12.19m x 2.44m x 2.90m',
|
|
||||||
internal: '12.03m x 2.35m x 2.69m',
|
|
||||||
door: '2.34m x 2.58m',
|
|
||||||
},
|
|
||||||
capacity: {
|
|
||||||
volume: '76 m³',
|
|
||||||
payload: '26,460 kg',
|
|
||||||
tare: '4,020 kg',
|
|
||||||
},
|
|
||||||
usage: 'Cargo léger mais volumineux',
|
|
||||||
icon: '📦',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'Reefer (Réfrigéré)',
|
|
||||||
code: '20RF / 40RF',
|
|
||||||
dimensions: {
|
|
||||||
external: 'Comme standard',
|
|
||||||
internal: 'Légèrement réduit (isolation)',
|
|
||||||
door: 'Standard',
|
|
||||||
},
|
|
||||||
capacity: {
|
|
||||||
volume: '28 m³ (20\') / 60 m³ (40\')',
|
|
||||||
payload: '27,400 kg / 26,500 kg',
|
|
||||||
temperature: '-30°C à +30°C',
|
|
||||||
},
|
|
||||||
usage: 'Produits périssables, pharmaceutiques',
|
|
||||||
icon: '❄️',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'Open Top',
|
|
||||||
code: '20OT / 40OT',
|
|
||||||
dimensions: {
|
|
||||||
external: 'Comme standard',
|
|
||||||
internal: 'Comme standard',
|
|
||||||
door: 'Toit amovible + portes arrière',
|
|
||||||
},
|
|
||||||
capacity: {
|
|
||||||
volume: 'Comme standard',
|
|
||||||
payload: '28,100 kg / 26,400 kg',
|
|
||||||
tare: '2,400 kg / 4,100 kg',
|
|
||||||
},
|
|
||||||
usage: 'Cargo hors gabarit en hauteur, machinerie',
|
|
||||||
icon: '📭',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'Flat Rack',
|
|
||||||
code: '20FR / 40FR',
|
|
||||||
dimensions: {
|
|
||||||
external: 'Comme standard (sans parois)',
|
|
||||||
internal: 'Plateau sans côtés',
|
|
||||||
door: 'N/A',
|
|
||||||
},
|
|
||||||
capacity: {
|
|
||||||
volume: 'N/A',
|
|
||||||
payload: '31,000 kg / 40,000 kg',
|
|
||||||
tare: '2,700 kg / 4,700 kg',
|
|
||||||
},
|
|
||||||
usage: 'Cargo très lourd ou surdimensionné',
|
|
||||||
icon: '🚛',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'Tank Container',
|
|
||||||
code: '20TK',
|
|
||||||
dimensions: {
|
|
||||||
external: 'Cadre standard 20\'',
|
|
||||||
internal: 'Citerne 21,000-26,000 L',
|
|
||||||
door: 'Valves et trappes',
|
|
||||||
},
|
|
||||||
capacity: {
|
|
||||||
volume: '21,000-26,000 litres',
|
|
||||||
payload: '26,000 kg',
|
|
||||||
tare: '3,500 kg',
|
|
||||||
},
|
|
||||||
usage: 'Liquides, gaz, produits chimiques',
|
|
||||||
icon: '🛢️',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const specialEquipment = [
|
|
||||||
{ name: 'Flexitank', desc: 'Poche flexible pour liquides non dangereux dans un 20\' standard', capacity: '16,000-24,000 L' },
|
|
||||||
{ name: 'Garment on Hanger (GOH)', desc: 'Barres pour vêtements suspendus', capacity: 'Variable' },
|
|
||||||
{ name: 'Ventilated Container', desc: 'Aération naturelle pour café, cacao, oignons', capacity: 'Standard' },
|
|
||||||
{ name: 'Insulated Container', desc: 'Isolation thermique sans réfrigération active', capacity: 'Standard' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function ConteneursPage() {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Header with back link */}
|
|
||||||
<div className="flex items-center gap-4 mb-6">
|
|
||||||
<Link
|
|
||||||
href="/dashboard/wiki"
|
|
||||||
className="flex items-center text-blue-600 hover:text-blue-800 transition-colors"
|
|
||||||
>
|
|
||||||
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
|
||||||
</svg>
|
|
||||||
Retour au Wiki
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Title */}
|
|
||||||
<div className="mb-8">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="text-4xl">📦</span>
|
|
||||||
<h1 className="text-3xl font-bold text-gray-900">Conteneurs et Types de Cargo</h1>
|
|
||||||
</div>
|
|
||||||
<p className="mt-3 text-gray-600 max-w-3xl">
|
|
||||||
Les conteneurs maritimes sont standardisés selon les normes ISO. Comprendre les différents
|
|
||||||
types permet de choisir le conteneur adapté à votre marchandise et d'optimiser les coûts.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Quick Reference */}
|
|
||||||
<Card className="bg-blue-50 border-blue-200">
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<h3 className="font-semibold text-blue-900 mb-3">Codes ISO Courants</h3>
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
|
||||||
{[
|
|
||||||
{ code: '20DC', name: '20\' Dry' },
|
|
||||||
{ code: '40DC', name: '40\' Dry' },
|
|
||||||
{ code: '40HC', name: '40\' High Cube' },
|
|
||||||
{ code: '45HC', name: '45\' High Cube' },
|
|
||||||
{ code: '20RF', name: '20\' Reefer' },
|
|
||||||
{ code: '40RF', name: '40\' Reefer' },
|
|
||||||
{ code: '20OT', name: '20\' Open Top' },
|
|
||||||
{ code: '20FR', name: '20\' Flat Rack' },
|
|
||||||
].map((item) => (
|
|
||||||
<div key={item.code} className="bg-white p-2 rounded text-center">
|
|
||||||
<span className="font-mono font-bold text-blue-700">{item.code}</span>
|
|
||||||
<p className="text-xs text-gray-600">{item.name}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Container Types */}
|
|
||||||
<h2 className="text-xl font-bold text-gray-900 mt-8 mb-4">Types de Conteneurs</h2>
|
|
||||||
<div className="space-y-4">
|
|
||||||
{containers.map((container) => (
|
|
||||||
<Card key={container.code} className="bg-white">
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="text-2xl">{container.icon}</span>
|
|
||||||
<div>
|
|
||||||
<span className="text-lg">{container.type}</span>
|
|
||||||
<span className="ml-2 px-2 py-1 bg-gray-100 text-gray-700 text-xs font-mono rounded">
|
|
||||||
{container.code}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-gray-600 mb-4">{container.usage}</p>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div className="bg-gray-50 p-3 rounded-lg">
|
|
||||||
<p className="text-sm font-medium text-gray-700 mb-2">Dimensions</p>
|
|
||||||
<ul className="space-y-1 text-sm">
|
|
||||||
<li className="flex justify-between">
|
|
||||||
<span className="text-gray-500">Externe:</span>
|
|
||||||
<span className="text-gray-900">{container.dimensions.external}</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex justify-between">
|
|
||||||
<span className="text-gray-500">Interne:</span>
|
|
||||||
<span className="text-gray-900">{container.dimensions.internal}</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex justify-between">
|
|
||||||
<span className="text-gray-500">Porte:</span>
|
|
||||||
<span className="text-gray-900">{container.dimensions.door}</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div className="bg-gray-50 p-3 rounded-lg">
|
|
||||||
<p className="text-sm font-medium text-gray-700 mb-2">Capacité</p>
|
|
||||||
<ul className="space-y-1 text-sm">
|
|
||||||
{Object.entries(container.capacity).map(([key, value]) => (
|
|
||||||
<li key={key} className="flex justify-between">
|
|
||||||
<span className="text-gray-500 capitalize">{key}:</span>
|
|
||||||
<span className="text-gray-900">{value}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Special Equipment */}
|
|
||||||
<h2 className="text-xl font-bold text-gray-900 mt-8 mb-4">Équipements Spéciaux</h2>
|
|
||||||
<Card className="bg-white">
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
{specialEquipment.map((equip) => (
|
|
||||||
<div key={equip.name} className="p-4 bg-gray-50 rounded-lg">
|
|
||||||
<h4 className="font-semibold text-gray-900">{equip.name}</h4>
|
|
||||||
<p className="text-sm text-gray-600 mt-1">{equip.desc}</p>
|
|
||||||
<p className="text-sm text-blue-600 mt-2">Capacité: {equip.capacity}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Container Selection Guide */}
|
|
||||||
<Card className="mt-8 bg-green-50 border-green-200">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-green-900">Guide de Sélection</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<span className="text-xl">📦</span>
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-green-900">Marchandises générales</p>
|
|
||||||
<p className="text-sm text-green-800">→ 20' ou 40' Standard (DRY)</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<span className="text-xl">❄️</span>
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-green-900">Produits réfrigérés/congelés</p>
|
|
||||||
<p className="text-sm text-green-800">→ Reefer 20' ou 40'</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<span className="text-xl">📭</span>
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-green-900">Cargo hors gabarit (hauteur)</p>
|
|
||||||
<p className="text-sm text-green-800">→ Open Top</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<span className="text-xl">🚛</span>
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-green-900">Machinerie lourde/surdimensionnée</p>
|
|
||||||
<p className="text-sm text-green-800">→ Flat Rack</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<span className="text-xl">🛢️</span>
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-green-900">Liquides en vrac</p>
|
|
||||||
<p className="text-sm text-green-800">→ Tank Container ou Flexitank</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Tips */}
|
|
||||||
<Card className="mt-8 bg-amber-50 border-amber-200">
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<h3 className="font-semibold text-amber-900 mb-3">Conseils Pratiques</h3>
|
|
||||||
<ul className="list-disc list-inside space-y-2 text-amber-800">
|
|
||||||
<li>Un 40' HC offre 30% de volume en plus qu'un 20' mais coûte rarement le double</li>
|
|
||||||
<li>Les Reefer nécessitent une alimentation électrique au port et sur le navire</li>
|
|
||||||
<li>Les conteneurs spéciaux (OT, FR, Tank) ont une disponibilité limitée - réservez à l'avance</li>
|
|
||||||
<li>Vérifiez le poids maximum autorisé sur les routes du pays de destination</li>
|
|
||||||
</ul>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,249 +0,0 @@
|
|||||||
/**
|
|
||||||
* Documents de Transport Maritime - Wiki Page
|
|
||||||
*
|
|
||||||
* Essential documents for maritime shipping
|
|
||||||
*/
|
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
|
||||||
|
|
||||||
const documents = [
|
|
||||||
{
|
|
||||||
name: 'Bill of Lading (B/L)',
|
|
||||||
french: 'Connaissement',
|
|
||||||
description: 'Document principal du transport maritime. Il fait preuve du contrat de transport, accuse réception des marchandises et constitue un titre de propriété négociable.',
|
|
||||||
types: [
|
|
||||||
{ name: 'B/L à ordre', desc: 'Négociable, peut être endossé' },
|
|
||||||
{ name: 'B/L nominatif', desc: 'Au nom d\'un destinataire précis' },
|
|
||||||
{ name: 'B/L au porteur', desc: 'Propriété à celui qui le détient' },
|
|
||||||
],
|
|
||||||
importance: 'Critique',
|
|
||||||
icon: '📄',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Sea Waybill',
|
|
||||||
french: 'Lettre de transport maritime',
|
|
||||||
description: 'Document non négociable servant de preuve du contrat de transport et de reçu. Plus simple que le B/L car pas besoin de présenter l\'original pour retirer les marchandises.',
|
|
||||||
types: [
|
|
||||||
{ name: 'Standard', desc: 'Pour expéditions entre parties de confiance' },
|
|
||||||
{ name: 'Express', desc: 'Libération rapide sans documents originaux' },
|
|
||||||
],
|
|
||||||
importance: 'Important',
|
|
||||||
icon: '📋',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Manifest',
|
|
||||||
french: 'Manifeste de cargaison',
|
|
||||||
description: 'Liste complète de toutes les marchandises chargées à bord d\'un navire. Utilisé par les autorités douanières et portuaires.',
|
|
||||||
types: [
|
|
||||||
{ name: 'Cargo Manifest', desc: 'Liste détaillée des marchandises' },
|
|
||||||
{ name: 'Freight Manifest', desc: 'Inclut les informations de fret' },
|
|
||||||
],
|
|
||||||
importance: 'Obligatoire',
|
|
||||||
icon: '📑',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Packing List',
|
|
||||||
french: 'Liste de colisage',
|
|
||||||
description: 'Document détaillant le contenu de chaque colis, ses dimensions et son poids. Essentiel pour le dédouanement.',
|
|
||||||
types: [
|
|
||||||
{ name: 'Simple', desc: 'Liste basique des contenus' },
|
|
||||||
{ name: 'Détaillée', desc: 'Avec poids, dimensions, marquage' },
|
|
||||||
],
|
|
||||||
importance: 'Important',
|
|
||||||
icon: '📦',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Commercial Invoice',
|
|
||||||
french: 'Facture commerciale',
|
|
||||||
description: 'Facture établie par le vendeur décrivant les marchandises, leur valeur et les conditions de vente. Base pour le calcul des droits de douane.',
|
|
||||||
types: [
|
|
||||||
{ name: 'Proforma', desc: 'Avant expédition, pour cotation' },
|
|
||||||
{ name: 'Définitive', desc: 'Document final de facturation' },
|
|
||||||
],
|
|
||||||
importance: 'Critique',
|
|
||||||
icon: '🧾',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Certificate of Origin',
|
|
||||||
french: 'Certificat d\'origine',
|
|
||||||
description: 'Document certifiant le pays de fabrication ou de transformation des marchandises. Requis pour les préférences tarifaires.',
|
|
||||||
types: [
|
|
||||||
{ name: 'EUR.1', desc: 'Pour les échanges UE' },
|
|
||||||
{ name: 'Form A', desc: 'Système de préférences généralisées' },
|
|
||||||
{ name: 'Non préférentiel', desc: 'Attestation simple d\'origine' },
|
|
||||||
],
|
|
||||||
importance: 'Selon destination',
|
|
||||||
icon: '🏭',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const additionalDocs = [
|
|
||||||
{
|
|
||||||
name: 'Dangerous Goods Declaration',
|
|
||||||
description: 'Obligatoire pour les marchandises dangereuses (IMDG)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Phytosanitary Certificate',
|
|
||||||
description: 'Pour les produits végétaux et alimentaires',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Insurance Certificate',
|
|
||||||
description: 'Preuve de couverture d\'assurance cargo',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Inspection Certificate',
|
|
||||||
description: 'Rapport de contrôle qualité avant embarquement',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'VGM Declaration',
|
|
||||||
description: 'Déclaration du poids vérifié du conteneur (SOLAS)',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function DocumentsTransportPage() {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Header with back link */}
|
|
||||||
<div className="flex items-center gap-4 mb-6">
|
|
||||||
<Link
|
|
||||||
href="/dashboard/wiki"
|
|
||||||
className="flex items-center text-blue-600 hover:text-blue-800 transition-colors"
|
|
||||||
>
|
|
||||||
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
|
||||||
</svg>
|
|
||||||
Retour au Wiki
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Title */}
|
|
||||||
<div className="mb-8">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="text-4xl">📋</span>
|
|
||||||
<h1 className="text-3xl font-bold text-gray-900">Documents de Transport Maritime</h1>
|
|
||||||
</div>
|
|
||||||
<p className="mt-3 text-gray-600 max-w-3xl">
|
|
||||||
Le transport maritime international nécessite une documentation précise et complète.
|
|
||||||
Ces documents servent de preuve de propriété, de contrat de transport et sont essentiels
|
|
||||||
pour le passage en douane.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Importance Warning */}
|
|
||||||
<Card className="bg-red-50 border-red-200">
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<h3 className="font-semibold text-red-900 mb-2">Important</h3>
|
|
||||||
<p className="text-red-800">
|
|
||||||
Une erreur ou un manque de document peut entraîner des retards considérables, des frais
|
|
||||||
de surestaries (demurrage) et même le refus d'entrée des marchandises dans le pays de destination.
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Main Documents */}
|
|
||||||
<h2 className="text-xl font-bold text-gray-900 mt-8 mb-4">Documents Principaux</h2>
|
|
||||||
<div className="space-y-4">
|
|
||||||
{documents.map((doc) => (
|
|
||||||
<Card key={doc.name} className="bg-white">
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="text-2xl">{doc.icon}</span>
|
|
||||||
<div>
|
|
||||||
<span className="text-lg">{doc.name}</span>
|
|
||||||
<span className="text-gray-500 text-sm ml-2">({doc.french})</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span className={`px-3 py-1 text-xs font-medium rounded-full ${
|
|
||||||
doc.importance === 'Critique' ? 'bg-red-100 text-red-800' :
|
|
||||||
doc.importance === 'Obligatoire' ? 'bg-orange-100 text-orange-800' :
|
|
||||||
'bg-blue-100 text-blue-800'
|
|
||||||
}`}>
|
|
||||||
{doc.importance}
|
|
||||||
</span>
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-gray-600 mb-4">{doc.description}</p>
|
|
||||||
<div className="bg-gray-50 p-3 rounded-lg">
|
|
||||||
<p className="text-sm font-medium text-gray-700 mb-2">Types:</p>
|
|
||||||
<ul className="space-y-1">
|
|
||||||
{doc.types.map((type) => (
|
|
||||||
<li key={type.name} className="text-sm flex">
|
|
||||||
<span className="font-medium text-gray-900 min-w-[120px]">{type.name}:</span>
|
|
||||||
<span className="text-gray-600">{type.desc}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Bill of Lading Detail */}
|
|
||||||
<Card className="mt-8 bg-blue-50 border-blue-200">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-blue-900">Focus: Le Bill of Lading (B/L)</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-blue-800 mb-4">
|
|
||||||
Le B/L remplit trois fonctions essentielles:
|
|
||||||
</p>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
||||||
<div className="bg-white p-4 rounded-lg">
|
|
||||||
<h4 className="font-semibold text-blue-900 mb-2">1. Reçu</h4>
|
|
||||||
<p className="text-sm text-gray-600">
|
|
||||||
Confirme que le transporteur a reçu les marchandises dans l'état décrit
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white p-4 rounded-lg">
|
|
||||||
<h4 className="font-semibold text-blue-900 mb-2">2. Contrat</h4>
|
|
||||||
<p className="text-sm text-gray-600">
|
|
||||||
Preuve du contrat de transport entre le chargeur et le transporteur
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white p-4 rounded-lg">
|
|
||||||
<h4 className="font-semibold text-blue-900 mb-2">3. Titre</h4>
|
|
||||||
<p className="text-sm text-gray-600">
|
|
||||||
Document de titre permettant le transfert de propriété des marchandises
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Additional Documents */}
|
|
||||||
<h2 className="text-xl font-bold text-gray-900 mt-8 mb-4">Documents Complémentaires</h2>
|
|
||||||
<Card className="bg-white">
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
{additionalDocs.map((doc) => (
|
|
||||||
<div key={doc.name} className="flex items-start gap-3 p-3 bg-gray-50 rounded-lg">
|
|
||||||
<span className="text-green-600">✓</span>
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-gray-900">{doc.name}</p>
|
|
||||||
<p className="text-sm text-gray-600">{doc.description}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Tips */}
|
|
||||||
<Card className="mt-8 bg-amber-50 border-amber-200">
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<h3 className="font-semibold text-amber-900 mb-3">Conseils Pratiques</h3>
|
|
||||||
<ul className="list-disc list-inside space-y-2 text-amber-800">
|
|
||||||
<li>Vérifiez la cohérence entre tous les documents (noms, adresses, descriptions)</li>
|
|
||||||
<li>Conservez des copies de tous les originaux</li>
|
|
||||||
<li>Anticipez les délais d'obtention des certificats (origine, sanitaire, etc.)</li>
|
|
||||||
<li>En cas de L/C, les documents doivent correspondre exactement aux exigences</li>
|
|
||||||
</ul>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,218 +0,0 @@
|
|||||||
/**
|
|
||||||
* Procédures Douanières - Wiki Page
|
|
||||||
*
|
|
||||||
* Guide des formalités douanières import/export
|
|
||||||
*/
|
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
|
||||||
|
|
||||||
const regimesDouaniers = [
|
|
||||||
{
|
|
||||||
code: 'IM4',
|
|
||||||
name: 'Mise en libre pratique',
|
|
||||||
description: 'Importation définitive avec paiement des droits et taxes. La marchandise entre dans le circuit économique.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: 'IM5',
|
|
||||||
name: 'Admission temporaire',
|
|
||||||
description: 'Importation temporaire avec suspension des droits. Pour les marchandises réexportées en l\'état.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: 'IM6',
|
|
||||||
name: 'Perfectionnement actif',
|
|
||||||
description: 'Importation pour transformation et réexportation. Suspension des droits sur les intrants.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: 'IM7',
|
|
||||||
name: 'Entrepôt douanier',
|
|
||||||
description: 'Stockage sous douane sans paiement des droits jusqu\'à la mise en consommation.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: 'EX1',
|
|
||||||
name: 'Exportation définitive',
|
|
||||||
description: 'Sortie définitive des marchandises du territoire douanier.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: 'EX2',
|
|
||||||
name: 'Exportation temporaire',
|
|
||||||
description: 'Sortie temporaire avec réimportation prévue en l\'état.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: 'EX3',
|
|
||||||
name: 'Perfectionnement passif',
|
|
||||||
description: 'Exportation pour transformation à l\'étranger et réimportation.',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const documentsDouane = [
|
|
||||||
{
|
|
||||||
name: 'DAU (Document Administratif Unique)',
|
|
||||||
description: 'Formulaire standard pour toutes les déclarations douanières dans l\'UE.',
|
|
||||||
obligatoire: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Facture commerciale',
|
|
||||||
description: 'Document de base indiquant la valeur des marchandises.',
|
|
||||||
obligatoire: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Liste de colisage',
|
|
||||||
description: 'Détail du contenu de chaque colis (poids, dimensions, contenu).',
|
|
||||||
obligatoire: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Certificat d\'origine',
|
|
||||||
description: 'Atteste l\'origine des marchandises pour les accords préférentiels.',
|
|
||||||
obligatoire: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Licence d\'importation/exportation',
|
|
||||||
description: 'Autorisation pour certaines marchandises réglementées.',
|
|
||||||
obligatoire: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Certificat sanitaire/phytosanitaire',
|
|
||||||
description: 'Pour les produits alimentaires, animaux et végétaux.',
|
|
||||||
obligatoire: false,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function DouanesPage() {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Header with back link */}
|
|
||||||
<div className="flex items-center gap-4 mb-6">
|
|
||||||
<Link
|
|
||||||
href="/dashboard/wiki"
|
|
||||||
className="flex items-center text-blue-600 hover:text-blue-800 transition-colors"
|
|
||||||
>
|
|
||||||
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
|
||||||
</svg>
|
|
||||||
Retour au Wiki
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Title */}
|
|
||||||
<div className="mb-8">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="text-4xl">🛃</span>
|
|
||||||
<h1 className="text-3xl font-bold text-gray-900">Procédures Douanières</h1>
|
|
||||||
</div>
|
|
||||||
<p className="mt-3 text-gray-600 max-w-3xl">
|
|
||||||
Les formalités douanières sont obligatoires pour toute marchandise traversant une frontière.
|
|
||||||
Elles permettent le contrôle des échanges, la perception des droits et taxes, et l'application
|
|
||||||
des réglementations commerciales.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Key Concepts */}
|
|
||||||
<Card className="bg-blue-50 border-blue-200">
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<h3 className="font-semibold text-blue-900 mb-3">Concepts Clés</h3>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-blue-800">
|
|
||||||
<div>
|
|
||||||
<h4 className="font-medium">Valeur en douane</h4>
|
|
||||||
<p className="text-sm">Base de calcul des droits, généralement la valeur CIF (coût + assurance + fret).</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 className="font-medium">Code SH / NC</h4>
|
|
||||||
<p className="text-sm">Classification tarifaire harmonisée des marchandises (6 ou 8 chiffres).</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 className="font-medium">Origine</h4>
|
|
||||||
<p className="text-sm">Pays de fabrication ou de transformation substantielle de la marchandise.</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 className="font-medium">OEA (Opérateur Économique Agréé)</h4>
|
|
||||||
<p className="text-sm">Statut de confiance accordé par les douanes pour des procédures simplifiées.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Régimes douaniers */}
|
|
||||||
<div className="mt-8">
|
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-4">📋 Régimes Douaniers</h2>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
{regimesDouaniers.map((regime) => (
|
|
||||||
<Card key={regime.code} className="bg-white">
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<span className="px-3 py-1 bg-green-600 text-white rounded-md font-mono text-sm">
|
|
||||||
{regime.code}
|
|
||||||
</span>
|
|
||||||
<span className="text-lg">{regime.name}</span>
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-gray-600">{regime.description}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Documents requis */}
|
|
||||||
<div className="mt-8">
|
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-4">📄 Documents Requis</h2>
|
|
||||||
<Card className="bg-white">
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<div className="space-y-4">
|
|
||||||
{documentsDouane.map((doc) => (
|
|
||||||
<div key={doc.name} className="flex items-start gap-3 pb-4 border-b last:border-0">
|
|
||||||
<span className={`px-2 py-1 text-xs rounded ${doc.obligatoire ? 'bg-red-100 text-red-700' : 'bg-gray-100 text-gray-700'}`}>
|
|
||||||
{doc.obligatoire ? 'Obligatoire' : 'Selon cas'}
|
|
||||||
</span>
|
|
||||||
<div>
|
|
||||||
<h4 className="font-medium text-gray-900">{doc.name}</h4>
|
|
||||||
<p className="text-sm text-gray-600">{doc.description}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Droits et taxes */}
|
|
||||||
<Card className="mt-8 bg-gray-50">
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<h3 className="font-semibold text-gray-900 mb-3">💰 Droits et Taxes</h3>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
||||||
<div className="bg-white p-4 rounded-lg border">
|
|
||||||
<h4 className="font-medium text-gray-900">Droits de douane</h4>
|
|
||||||
<p className="text-sm text-gray-600">Pourcentage appliqué sur la valeur en douane selon le code SH.</p>
|
|
||||||
<p className="text-xs text-gray-500 mt-2">Ex: 0% à 17% selon les produits</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white p-4 rounded-lg border">
|
|
||||||
<h4 className="font-medium text-gray-900">TVA import</h4>
|
|
||||||
<p className="text-sm text-gray-600">Taxe sur la valeur ajoutée calculée sur (valeur + droits).</p>
|
|
||||||
<p className="text-xs text-gray-500 mt-2">France: 20%, 10%, 5.5% ou 2.1%</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white p-4 rounded-lg border">
|
|
||||||
<h4 className="font-medium text-gray-900">Droits antidumping</h4>
|
|
||||||
<p className="text-sm text-gray-600">Droits additionnels pour protéger contre la concurrence déloyale.</p>
|
|
||||||
<p className="text-xs text-gray-500 mt-2">Variable selon origine et produit</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Tips */}
|
|
||||||
<Card className="mt-8 bg-amber-50 border-amber-200">
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<h3 className="font-semibold text-amber-900 mb-3">⚠️ Points d'Attention</h3>
|
|
||||||
<ul className="list-disc list-inside space-y-2 text-amber-800">
|
|
||||||
<li>Toujours vérifier le classement tarifaire avant l'importation</li>
|
|
||||||
<li>Conserver tous les documents 3 ans minimum (contrôle a posteriori)</li>
|
|
||||||
<li>Anticiper les contrôles : certificats, licences, normes</li>
|
|
||||||
<li>Utiliser les accords de libre-échange pour réduire les droits</li>
|
|
||||||
<li>Attention aux marchandises à double usage (exportation)</li>
|
|
||||||
</ul>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,312 +0,0 @@
|
|||||||
/**
|
|
||||||
* Marchandises Dangereuses (IMDG) - Wiki Page
|
|
||||||
*
|
|
||||||
* Transport de matières dangereuses par mer
|
|
||||||
*/
|
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
|
||||||
|
|
||||||
const classesIMDG = [
|
|
||||||
{
|
|
||||||
class: '1',
|
|
||||||
name: 'Explosifs',
|
|
||||||
description: 'Matières et objets explosibles',
|
|
||||||
examples: 'Munitions, feux d\'artifice, détonateurs',
|
|
||||||
color: 'bg-orange-500',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
class: '2',
|
|
||||||
name: 'Gaz',
|
|
||||||
description: 'Gaz comprimés, liquéfiés ou dissous',
|
|
||||||
examples: 'Propane, aérosols, oxygène, extincteurs',
|
|
||||||
color: 'bg-green-500',
|
|
||||||
subdivisions: ['2.1 Inflammables', '2.2 Non inflammables', '2.3 Toxiques'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
class: '3',
|
|
||||||
name: 'Liquides inflammables',
|
|
||||||
description: 'Liquides à point d\'éclair bas',
|
|
||||||
examples: 'Essence, peintures, alcools, solvants',
|
|
||||||
color: 'bg-red-500',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
class: '4',
|
|
||||||
name: 'Solides inflammables',
|
|
||||||
description: 'Solides facilement inflammables ou auto-réactifs',
|
|
||||||
examples: 'Allumettes, soufre, métaux en poudre',
|
|
||||||
color: 'bg-red-400',
|
|
||||||
subdivisions: ['4.1 Inflammables', '4.2 Auto-inflammables', '4.3 Réagissent avec l\'eau'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
class: '5',
|
|
||||||
name: 'Comburants et peroxydes',
|
|
||||||
description: 'Matières qui favorisent la combustion',
|
|
||||||
examples: 'Engrais, peroxydes, agents de blanchiment',
|
|
||||||
color: 'bg-yellow-500',
|
|
||||||
subdivisions: ['5.1 Comburants', '5.2 Peroxydes organiques'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
class: '6',
|
|
||||||
name: 'Matières toxiques et infectieuses',
|
|
||||||
description: 'Matières nocives pour la santé',
|
|
||||||
examples: 'Pesticides, échantillons médicaux, cyanures',
|
|
||||||
color: 'bg-purple-500',
|
|
||||||
subdivisions: ['6.1 Toxiques', '6.2 Infectieuses'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
class: '7',
|
|
||||||
name: 'Matières radioactives',
|
|
||||||
description: 'Matières émettant des radiations',
|
|
||||||
examples: 'Isotopes médicaux, sources industrielles',
|
|
||||||
color: 'bg-yellow-300',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
class: '8',
|
|
||||||
name: 'Matières corrosives',
|
|
||||||
description: 'Matières attaquant les tissus ou métaux',
|
|
||||||
examples: 'Acide sulfurique, soude caustique, batteries',
|
|
||||||
color: 'bg-gray-700',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
class: '9',
|
|
||||||
name: 'Matières dangereuses diverses',
|
|
||||||
description: 'Autres matières présentant un danger',
|
|
||||||
examples: 'Batteries lithium, amiante, glace carbonique',
|
|
||||||
color: 'bg-gray-400',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const documentsRequired = [
|
|
||||||
{ name: 'DGD (Dangerous Goods Declaration)', description: 'Déclaration obligatoire signée par l\'expéditeur' },
|
|
||||||
{ name: 'Multimodal Dangerous Goods Form', description: 'Formulaire standard OMI/OIT pour le transport multimodal' },
|
|
||||||
{ name: 'Fiche de Données de Sécurité (FDS/SDS)', description: 'Document technique détaillant les risques et mesures' },
|
|
||||||
{ name: 'Certificat d\'empotage', description: 'Attestation de bon chargement du conteneur' },
|
|
||||||
{ name: 'Approval/Exemption', description: 'Autorisations spécifiques pour certaines matières' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const packagingGroups = [
|
|
||||||
{ group: 'I', danger: 'Élevé', description: 'Matières très dangereuses' },
|
|
||||||
{ group: 'II', danger: 'Moyen', description: 'Matières moyennement dangereuses' },
|
|
||||||
{ group: 'III', danger: 'Faible', description: 'Matières légèrement dangereuses' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function IMDGPage() {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Header with back link */}
|
|
||||||
<div className="flex items-center gap-4 mb-6">
|
|
||||||
<Link
|
|
||||||
href="/dashboard/wiki"
|
|
||||||
className="flex items-center text-blue-600 hover:text-blue-800 transition-colors"
|
|
||||||
>
|
|
||||||
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
|
||||||
</svg>
|
|
||||||
Retour au Wiki
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Title */}
|
|
||||||
<div className="mb-8">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="text-4xl">⚠️</span>
|
|
||||||
<h1 className="text-3xl font-bold text-gray-900">Marchandises Dangereuses (Code IMDG)</h1>
|
|
||||||
</div>
|
|
||||||
<p className="mt-3 text-gray-600 max-w-3xl">
|
|
||||||
Le Code IMDG (International Maritime Dangerous Goods) est le référentiel international pour
|
|
||||||
le transport maritime de marchandises dangereuses. Publié par l'OMI, il est mis à jour tous
|
|
||||||
les deux ans et est obligatoire depuis janvier 2004.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Key Info */}
|
|
||||||
<Card className="bg-red-50 border-red-200">
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<h3 className="font-semibold text-red-900 mb-3">⚠️ Responsabilités de l'Expéditeur</h3>
|
|
||||||
<ul className="list-disc list-inside space-y-2 text-red-800">
|
|
||||||
<li>Classer correctement la marchandise selon le Code IMDG</li>
|
|
||||||
<li>Utiliser des emballages homologués UN</li>
|
|
||||||
<li>Étiqueter et marquer conformément aux exigences</li>
|
|
||||||
<li>Remplir la déclaration de marchandises dangereuses (DGD)</li>
|
|
||||||
<li>S'assurer de la formation du personnel impliqué</li>
|
|
||||||
</ul>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Classes */}
|
|
||||||
<div className="mt-8">
|
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-4">📋 Les 9 Classes de Danger</h2>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
||||||
{classesIMDG.map((cls) => (
|
|
||||||
<Card key={cls.class} className="bg-white overflow-hidden">
|
|
||||||
<div className={`h-2 ${cls.color}`}></div>
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle className="flex items-center gap-3">
|
|
||||||
<span className={`w-10 h-10 flex items-center justify-center text-white font-bold rounded ${cls.color}`}>
|
|
||||||
{cls.class}
|
|
||||||
</span>
|
|
||||||
<span className="text-base">{cls.name}</span>
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-gray-600 text-sm mb-2">{cls.description}</p>
|
|
||||||
<p className="text-xs text-gray-500">Ex: {cls.examples}</p>
|
|
||||||
{cls.subdivisions && (
|
|
||||||
<div className="mt-2 pt-2 border-t">
|
|
||||||
<p className="text-xs text-gray-400">Subdivisions:</p>
|
|
||||||
<ul className="text-xs text-gray-500">
|
|
||||||
{cls.subdivisions.map((sub) => (
|
|
||||||
<li key={sub}>• {sub}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* UN Number */}
|
|
||||||
<Card className="mt-8 bg-gray-50">
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<h3 className="font-semibold text-gray-900 mb-3">🔢 Numéro ONU (UN Number)</h3>
|
|
||||||
<p className="text-gray-600 mb-4">
|
|
||||||
Chaque matière dangereuse est identifiée par un numéro ONU à 4 chiffres.
|
|
||||||
Ce numéro permet de retrouver toutes les informations dans le Code IMDG.
|
|
||||||
</p>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
|
|
||||||
<div className="bg-white p-4 rounded-lg border">
|
|
||||||
<p className="font-mono text-lg text-blue-600">UN 1203</p>
|
|
||||||
<p className="text-gray-600">Essence</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white p-4 rounded-lg border">
|
|
||||||
<p className="font-mono text-lg text-blue-600">UN 2794</p>
|
|
||||||
<p className="text-gray-600">Batteries acide/plomb</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white p-4 rounded-lg border">
|
|
||||||
<p className="font-mono text-lg text-blue-600">UN 3481</p>
|
|
||||||
<p className="text-gray-600">Batteries lithium-ion</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Packaging Groups */}
|
|
||||||
<div className="mt-8">
|
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-4">📦 Groupes d'Emballage</h2>
|
|
||||||
<Card className="bg-white">
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<div className="space-y-4">
|
|
||||||
{packagingGroups.map((pg) => (
|
|
||||||
<div key={pg.group} className="flex items-center gap-4 p-3 rounded-lg bg-gray-50">
|
|
||||||
<span className={`w-12 h-12 flex items-center justify-center rounded-full font-bold text-white ${
|
|
||||||
pg.group === 'I' ? 'bg-red-600' : pg.group === 'II' ? 'bg-orange-500' : 'bg-yellow-500'
|
|
||||||
}`}>
|
|
||||||
{pg.group}
|
|
||||||
</span>
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-gray-900">Groupe {pg.group} - Danger {pg.danger}</p>
|
|
||||||
<p className="text-sm text-gray-600">{pg.description}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Documents */}
|
|
||||||
<div className="mt-8">
|
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-4">📄 Documents Requis</h2>
|
|
||||||
<Card className="bg-white">
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<div className="space-y-3">
|
|
||||||
{documentsRequired.map((doc) => (
|
|
||||||
<div key={doc.name} className="flex items-start gap-3 pb-3 border-b last:border-0">
|
|
||||||
<span className="mt-1 w-2 h-2 bg-blue-500 rounded-full flex-shrink-0"></span>
|
|
||||||
<div>
|
|
||||||
<h4 className="font-medium text-gray-900">{doc.name}</h4>
|
|
||||||
<p className="text-sm text-gray-600">{doc.description}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Labeling */}
|
|
||||||
<Card className="mt-8 bg-gray-50">
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<h3 className="font-semibold text-gray-900 mb-3">🏷️ Marquage et Étiquetage</h3>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div className="bg-white p-4 rounded-lg border">
|
|
||||||
<h4 className="font-medium text-gray-900">Colis</h4>
|
|
||||||
<ul className="text-sm text-gray-600 mt-2 space-y-1">
|
|
||||||
<li>• Étiquette(s) de danger (losanges)</li>
|
|
||||||
<li>• Numéro ONU précédé de "UN"</li>
|
|
||||||
<li>• Nom technique de la matière</li>
|
|
||||||
<li>• Marque d'homologation UN de l'emballage</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white p-4 rounded-lg border">
|
|
||||||
<h4 className="font-medium text-gray-900">Conteneur</h4>
|
|
||||||
<ul className="text-sm text-gray-600 mt-2 space-y-1">
|
|
||||||
<li>• Plaques-étiquettes (4 faces)</li>
|
|
||||||
<li>• Numéro ONU en grands caractères</li>
|
|
||||||
<li>• Certificat d'empotage affiché</li>
|
|
||||||
<li>• Marine Pollutant si applicable</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Segregation */}
|
|
||||||
<Card className="mt-8 bg-orange-50 border-orange-200">
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<h3 className="font-semibold text-orange-900 mb-3">🔀 Ségrégation</h3>
|
|
||||||
<p className="text-orange-800 mb-3">
|
|
||||||
Certaines classes de marchandises dangereuses ne peuvent pas être chargées ensemble.
|
|
||||||
Le Code IMDG définit des règles strictes de ségrégation :
|
|
||||||
</p>
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-sm">
|
|
||||||
<div className="bg-white p-2 rounded text-center">
|
|
||||||
<p className="font-medium">1</p>
|
|
||||||
<p className="text-xs text-gray-600">Away from</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white p-2 rounded text-center">
|
|
||||||
<p className="font-medium">2</p>
|
|
||||||
<p className="text-xs text-gray-600">Separated from</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white p-2 rounded text-center">
|
|
||||||
<p className="font-medium">3</p>
|
|
||||||
<p className="text-xs text-gray-600">Separated by compartment</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white p-2 rounded text-center">
|
|
||||||
<p className="font-medium">4</p>
|
|
||||||
<p className="text-xs text-gray-600">Separated longitudinally</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Tips */}
|
|
||||||
<Card className="mt-8 bg-amber-50 border-amber-200">
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<h3 className="font-semibold text-amber-900 mb-3">💡 Conseils Pratiques</h3>
|
|
||||||
<ul className="list-disc list-inside space-y-2 text-amber-800">
|
|
||||||
<li>Vérifier l'acceptation par la compagnie maritime (certaines refusent certaines classes)</li>
|
|
||||||
<li>Anticiper les surcharges DG (dangerous goods) qui peuvent être significatives</li>
|
|
||||||
<li>Former le personnel aux procédures d'urgence</li>
|
|
||||||
<li>Utiliser un transitaire spécialisé en marchandises dangereuses</li>
|
|
||||||
<li>Consulter les réglementations locales (certains ports ont des restrictions)</li>
|
|
||||||
<li>Batteries lithium : attention aux réglementations très strictes (UN 3480, 3481)</li>
|
|
||||||
</ul>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,224 +0,0 @@
|
|||||||
/**
|
|
||||||
* Incoterms 2020 - Wiki Page
|
|
||||||
*
|
|
||||||
* Detailed information about international commercial terms
|
|
||||||
*/
|
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
|
||||||
|
|
||||||
const incoterms = [
|
|
||||||
{
|
|
||||||
code: 'EXW',
|
|
||||||
name: 'Ex Works',
|
|
||||||
description: 'Le vendeur met la marchandise à disposition dans ses locaux. L\'acheteur assume tous les risques et coûts.',
|
|
||||||
risk: 'Acheteur',
|
|
||||||
transport: 'Tous modes',
|
|
||||||
category: 'Départ',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: 'FCA',
|
|
||||||
name: 'Free Carrier',
|
|
||||||
description: 'Le vendeur livre la marchandise au transporteur désigné par l\'acheteur.',
|
|
||||||
risk: 'Transfert à la livraison au transporteur',
|
|
||||||
transport: 'Tous modes',
|
|
||||||
category: 'Départ',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: 'CPT',
|
|
||||||
name: 'Carriage Paid To',
|
|
||||||
description: 'Le vendeur paie le transport jusqu\'à destination. Le risque est transféré à la remise au transporteur.',
|
|
||||||
risk: 'Transfert à la livraison au transporteur',
|
|
||||||
transport: 'Tous modes',
|
|
||||||
category: 'Arrivée',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: 'CIP',
|
|
||||||
name: 'Carriage and Insurance Paid To',
|
|
||||||
description: 'Comme CPT mais le vendeur doit aussi assurer la marchandise.',
|
|
||||||
risk: 'Transfert à la livraison au transporteur',
|
|
||||||
transport: 'Tous modes',
|
|
||||||
category: 'Arrivée',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: 'DAP',
|
|
||||||
name: 'Delivered at Place',
|
|
||||||
description: 'Le vendeur livre la marchandise prête à être déchargée au lieu convenu.',
|
|
||||||
risk: 'Vendeur jusqu\'à destination',
|
|
||||||
transport: 'Tous modes',
|
|
||||||
category: 'Arrivée',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: 'DPU',
|
|
||||||
name: 'Delivered at Place Unloaded',
|
|
||||||
description: 'Le vendeur livre et décharge la marchandise au lieu de destination.',
|
|
||||||
risk: 'Vendeur jusqu\'au déchargement',
|
|
||||||
transport: 'Tous modes',
|
|
||||||
category: 'Arrivée',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: 'DDP',
|
|
||||||
name: 'Delivered Duty Paid',
|
|
||||||
description: 'Le vendeur assume tous les risques et coûts, y compris les droits de douane.',
|
|
||||||
risk: 'Vendeur (maximum)',
|
|
||||||
transport: 'Tous modes',
|
|
||||||
category: 'Arrivée',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: 'FAS',
|
|
||||||
name: 'Free Alongside Ship',
|
|
||||||
description: 'Le vendeur livre la marchandise le long du navire au port d\'embarquement.',
|
|
||||||
risk: 'Transfert le long du navire',
|
|
||||||
transport: 'Maritime uniquement',
|
|
||||||
category: 'Maritime',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: 'FOB',
|
|
||||||
name: 'Free On Board',
|
|
||||||
description: 'Le vendeur livre la marchandise à bord du navire. Très utilisé en maritime.',
|
|
||||||
risk: 'Transfert à bord du navire',
|
|
||||||
transport: 'Maritime uniquement',
|
|
||||||
category: 'Maritime',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: 'CFR',
|
|
||||||
name: 'Cost and Freight',
|
|
||||||
description: 'Le vendeur paie le fret jusqu\'au port de destination. Le risque passe à bord.',
|
|
||||||
risk: 'Transfert à bord du navire',
|
|
||||||
transport: 'Maritime uniquement',
|
|
||||||
category: 'Maritime',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: 'CIF',
|
|
||||||
name: 'Cost, Insurance and Freight',
|
|
||||||
description: 'Comme CFR mais le vendeur doit aussi assurer la marchandise.',
|
|
||||||
risk: 'Transfert à bord du navire',
|
|
||||||
transport: 'Maritime uniquement',
|
|
||||||
category: 'Maritime',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function IncotermsPage() {
|
|
||||||
const categories = ['Départ', 'Arrivée', 'Maritime'];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Header with back link */}
|
|
||||||
<div className="flex items-center gap-4 mb-6">
|
|
||||||
<Link
|
|
||||||
href="/dashboard/wiki"
|
|
||||||
className="flex items-center text-blue-600 hover:text-blue-800 transition-colors"
|
|
||||||
>
|
|
||||||
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
|
||||||
</svg>
|
|
||||||
Retour au Wiki
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Title */}
|
|
||||||
<div className="mb-8">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="text-4xl">📜</span>
|
|
||||||
<h1 className="text-3xl font-bold text-gray-900">Incoterms 2020</h1>
|
|
||||||
</div>
|
|
||||||
<p className="mt-3 text-gray-600 max-w-3xl">
|
|
||||||
Les Incoterms (International Commercial Terms) sont des règles publiées par la Chambre de Commerce
|
|
||||||
Internationale (ICC) qui définissent les responsabilités des vendeurs et acheteurs dans les transactions
|
|
||||||
internationales. La version 2020 est entrée en vigueur le 1er janvier 2020.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Key Points */}
|
|
||||||
<Card className="bg-blue-50 border-blue-200">
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<h3 className="font-semibold text-blue-900 mb-3">Points Clés</h3>
|
|
||||||
<ul className="list-disc list-inside space-y-2 text-blue-800">
|
|
||||||
<li>11 Incoterms au total : 7 multimodaux et 4 maritimes</li>
|
|
||||||
<li>Définissent le transfert des risques et des coûts</li>
|
|
||||||
<li>Ne définissent PAS le transfert de propriété</li>
|
|
||||||
<li>Doivent être suivis de la version (ex: FOB Incoterms 2020)</li>
|
|
||||||
</ul>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Incoterms by category */}
|
|
||||||
{categories.map((category) => (
|
|
||||||
<div key={category} className="mt-8">
|
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-4">
|
|
||||||
{category === 'Maritime' ? '🚢 Incoterms Maritimes' :
|
|
||||||
category === 'Départ' ? '📤 Incoterms de Départ' : '📥 Incoterms d\'Arrivée'}
|
|
||||||
</h2>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
{incoterms
|
|
||||||
.filter((term) => term.category === category)
|
|
||||||
.map((term) => (
|
|
||||||
<Card key={term.code} className="bg-white">
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<span className="px-3 py-1 bg-blue-600 text-white rounded-md font-mono">
|
|
||||||
{term.code}
|
|
||||||
</span>
|
|
||||||
<span className="text-lg">{term.name}</span>
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-gray-600 mb-3">{term.description}</p>
|
|
||||||
<div className="space-y-1 text-sm">
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-gray-500">Risque:</span>
|
|
||||||
<span className="font-medium text-gray-900">{term.risk}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-gray-500">Transport:</span>
|
|
||||||
<span className="font-medium text-gray-900">{term.transport}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Visual diagram placeholder */}
|
|
||||||
<Card className="mt-8 bg-gray-50">
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<h3 className="font-semibold text-gray-900 mb-3">Transfert des Risques - Schéma</h3>
|
|
||||||
<div className="bg-white p-4 rounded-lg border">
|
|
||||||
<div className="flex items-center justify-between text-sm mb-2">
|
|
||||||
<span className="font-medium">Vendeur</span>
|
|
||||||
<span className="font-medium">Acheteur</span>
|
|
||||||
</div>
|
|
||||||
<div className="relative h-8 bg-gradient-to-r from-blue-500 to-green-500 rounded-full">
|
|
||||||
<div className="absolute inset-0 flex items-center justify-around text-white text-xs font-medium">
|
|
||||||
<span>EXW</span>
|
|
||||||
<span>FCA</span>
|
|
||||||
<span>FOB</span>
|
|
||||||
<span>CFR/CIF</span>
|
|
||||||
<span>DAP</span>
|
|
||||||
<span>DDP</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-gray-500 mt-2 text-center">
|
|
||||||
Plus on va vers la droite, plus le vendeur assume de responsabilités
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Tips */}
|
|
||||||
<Card className="mt-8 bg-amber-50 border-amber-200">
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<h3 className="font-semibold text-amber-900 mb-3">Conseils Pratiques</h3>
|
|
||||||
<ul className="list-disc list-inside space-y-2 text-amber-800">
|
|
||||||
<li>Utilisez FOB ou CIF pour le maritime, FCA ou CIP pour le multimodal</li>
|
|
||||||
<li>Précisez toujours le lieu exact (ex: FOB Shanghai Port)</li>
|
|
||||||
<li>Vérifiez la cohérence entre l'Incoterm et la lettre de crédit</li>
|
|
||||||
<li>EXW et DDP sont les termes extrêmes - à utiliser avec précaution</li>
|
|
||||||
</ul>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,283 +0,0 @@
|
|||||||
/**
|
|
||||||
* LCL vs FCL - Wiki Page
|
|
||||||
*
|
|
||||||
* Comparison between Less than Container Load and Full Container Load
|
|
||||||
*/
|
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
|
||||||
|
|
||||||
export default function LclVsFclPage() {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Header with back link */}
|
|
||||||
<div className="flex items-center gap-4 mb-6">
|
|
||||||
<Link
|
|
||||||
href="/dashboard/wiki"
|
|
||||||
className="flex items-center text-blue-600 hover:text-blue-800 transition-colors"
|
|
||||||
>
|
|
||||||
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
|
||||||
</svg>
|
|
||||||
Retour au Wiki
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Title */}
|
|
||||||
<div className="mb-8">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="text-4xl">⚖️</span>
|
|
||||||
<h1 className="text-3xl font-bold text-gray-900">LCL vs FCL</h1>
|
|
||||||
</div>
|
|
||||||
<p className="mt-3 text-gray-600 max-w-3xl">
|
|
||||||
Choisir entre LCL (Less than Container Load) et FCL (Full Container Load) est une décision
|
|
||||||
cruciale qui impacte les coûts, les délais et la sécurité de vos marchandises.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Definitions */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
<Card className="bg-blue-50 border-blue-200">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-blue-900 flex items-center gap-2">
|
|
||||||
<span className="text-2xl">📦</span>
|
|
||||||
LCL - Groupage
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-blue-800 mb-4">
|
|
||||||
<strong>Less than Container Load</strong> - Vos marchandises partagent un conteneur
|
|
||||||
avec d'autres expéditeurs. Vous payez uniquement pour l'espace utilisé.
|
|
||||||
</p>
|
|
||||||
<div className="bg-white p-3 rounded-lg">
|
|
||||||
<p className="text-sm font-medium text-blue-900 mb-2">Caractéristiques:</p>
|
|
||||||
<ul className="text-sm text-blue-800 space-y-1">
|
|
||||||
<li>• Volume: généralement < 15 m³</li>
|
|
||||||
<li>• Consolidation en entrepôt (CFS)</li>
|
|
||||||
<li>• Facturation au m³ ou tonne</li>
|
|
||||||
<li>• Délai transit plus long (+3-7 jours)</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="bg-green-50 border-green-200">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-green-900 flex items-center gap-2">
|
|
||||||
<span className="text-2xl">🚛</span>
|
|
||||||
FCL - Complet
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-green-800 mb-4">
|
|
||||||
<strong>Full Container Load</strong> - Vous réservez un conteneur entier, même s'il
|
|
||||||
n'est pas plein. Vos marchandises ne sont pas mélangées.
|
|
||||||
</p>
|
|
||||||
<div className="bg-white p-3 rounded-lg">
|
|
||||||
<p className="text-sm font-medium text-green-900 mb-2">Caractéristiques:</p>
|
|
||||||
<ul className="text-sm text-green-800 space-y-1">
|
|
||||||
<li>• Volume: 20', 40' ou 40'HC</li>
|
|
||||||
<li>• Chargement direct porte-à-porte</li>
|
|
||||||
<li>• Facturation par conteneur</li>
|
|
||||||
<li>• Transit direct (plus rapide)</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Comparison Table */}
|
|
||||||
<h2 className="text-xl font-bold text-gray-900 mt-8 mb-4">Comparaison Détaillée</h2>
|
|
||||||
<Card className="bg-white overflow-hidden">
|
|
||||||
<CardContent className="p-0">
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full">
|
|
||||||
<thead className="bg-gray-50">
|
|
||||||
<tr>
|
|
||||||
<th className="px-4 py-3 text-left text-sm font-semibold text-gray-900">Critère</th>
|
|
||||||
<th className="px-4 py-3 text-left text-sm font-semibold text-blue-700">LCL</th>
|
|
||||||
<th className="px-4 py-3 text-left text-sm font-semibold text-green-700">FCL</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-gray-200">
|
|
||||||
<tr>
|
|
||||||
<td className="px-4 py-3 text-sm font-medium text-gray-900">Coût</td>
|
|
||||||
<td className="px-4 py-3 text-sm text-gray-600">Payez au m³/tonne</td>
|
|
||||||
<td className="px-4 py-3 text-sm text-gray-600">Prix fixe par conteneur</td>
|
|
||||||
</tr>
|
|
||||||
<tr className="bg-gray-50">
|
|
||||||
<td className="px-4 py-3 text-sm font-medium text-gray-900">Seuil de rentabilité</td>
|
|
||||||
<td className="px-4 py-3 text-sm text-gray-600">< 10-15 m³</td>
|
|
||||||
<td className="px-4 py-3 text-sm text-gray-600">> 15 m³</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td className="px-4 py-3 text-sm font-medium text-gray-900">Transit time</td>
|
|
||||||
<td className="px-4 py-3 text-sm text-gray-600">+3-7 jours (consolidation)</td>
|
|
||||||
<td className="px-4 py-3 text-sm text-gray-600">Direct, plus rapide</td>
|
|
||||||
</tr>
|
|
||||||
<tr className="bg-gray-50">
|
|
||||||
<td className="px-4 py-3 text-sm font-medium text-gray-900">Manutention</td>
|
|
||||||
<td className="px-4 py-3 text-sm text-gray-600">Multiple (risque de dommage)</td>
|
|
||||||
<td className="px-4 py-3 text-sm text-gray-600">Minimale</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td className="px-4 py-3 text-sm font-medium text-gray-900">Sécurité</td>
|
|
||||||
<td className="px-4 py-3 text-sm text-gray-600">Marchandises mélangées</td>
|
|
||||||
<td className="px-4 py-3 text-sm text-gray-600">Conteneur scellé</td>
|
|
||||||
</tr>
|
|
||||||
<tr className="bg-gray-50">
|
|
||||||
<td className="px-4 py-3 text-sm font-medium text-gray-900">Flexibilité</td>
|
|
||||||
<td className="px-4 py-3 text-sm text-gray-600">Haute (petits volumes)</td>
|
|
||||||
<td className="px-4 py-3 text-sm text-gray-600">Moyenne</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td className="px-4 py-3 text-sm font-medium text-gray-900">Documentation</td>
|
|
||||||
<td className="px-4 py-3 text-sm text-gray-600">Plus complexe</td>
|
|
||||||
<td className="px-4 py-3 text-sm text-gray-600">Simplifiée</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Cost Calculation */}
|
|
||||||
<Card className="mt-8 bg-gray-50">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-gray-900">Calcul du Seuil de Rentabilité</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-gray-600 mb-4">
|
|
||||||
Le point où FCL devient plus économique que LCL dépend de la route et des tarifs.
|
|
||||||
Voici un exemple de calcul:
|
|
||||||
</p>
|
|
||||||
<div className="bg-white p-4 rounded-lg space-y-4">
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div className="p-3 border rounded-lg">
|
|
||||||
<p className="font-medium text-blue-700">LCL</p>
|
|
||||||
<p className="text-sm text-gray-600">Tarif: 80 €/m³</p>
|
|
||||||
<p className="text-sm text-gray-600">Pour 15 m³: 15 × 80 = <strong>1,200 €</strong></p>
|
|
||||||
</div>
|
|
||||||
<div className="p-3 border rounded-lg">
|
|
||||||
<p className="font-medium text-green-700">FCL 20'</p>
|
|
||||||
<p className="text-sm text-gray-600">Tarif conteneur: 1,100 €</p>
|
|
||||||
<p className="text-sm text-gray-600">Capacité: ~28 m³ pour <strong>1,100 €</strong></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-gray-700 bg-amber-50 p-3 rounded">
|
|
||||||
<strong>Conclusion:</strong> Dans cet exemple, le FCL devient rentable à partir de ~14 m³.
|
|
||||||
Demandez toujours des cotations LCL et FCL pour comparer.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* When to Choose */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mt-8">
|
|
||||||
<Card className="bg-blue-50 border-blue-200">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-blue-900">Choisissez LCL si:</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<ul className="space-y-2">
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<span className="text-blue-600">✓</span>
|
|
||||||
<span className="text-blue-800">Volume inférieur à 10-15 m³</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<span className="text-blue-600">✓</span>
|
|
||||||
<span className="text-blue-800">Expéditions régulières de petites quantités</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<span className="text-blue-600">✓</span>
|
|
||||||
<span className="text-blue-800">Test de nouveaux marchés</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<span className="text-blue-600">✓</span>
|
|
||||||
<span className="text-blue-800">Marchandises non fragiles</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<span className="text-blue-600">✓</span>
|
|
||||||
<span className="text-blue-800">Flexibilité sur les délais</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="bg-green-50 border-green-200">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-green-900">Choisissez FCL si:</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<ul className="space-y-2">
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<span className="text-green-600">✓</span>
|
|
||||||
<span className="text-green-800">Volume supérieur à 15 m³</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<span className="text-green-600">✓</span>
|
|
||||||
<span className="text-green-800">Marchandises de valeur ou fragiles</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<span className="text-green-600">✓</span>
|
|
||||||
<span className="text-green-800">Délais de livraison critiques</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<span className="text-green-600">✓</span>
|
|
||||||
<span className="text-green-800">Sécurité renforcée requise</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<span className="text-green-600">✓</span>
|
|
||||||
<span className="text-green-800">Marchandises incompatibles avec d'autres</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* LCL Process */}
|
|
||||||
<Card className="mt-8 bg-white">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-gray-900">Processus LCL (Groupage)</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="flex flex-col md:flex-row gap-4 items-center justify-between">
|
|
||||||
{[
|
|
||||||
{ step: '1', title: 'Collecte', desc: 'Enlèvement chez l\'expéditeur' },
|
|
||||||
{ step: '2', title: 'CFS Origine', desc: 'Consolidation en entrepôt' },
|
|
||||||
{ step: '3', title: 'Transport', desc: 'Acheminement maritime' },
|
|
||||||
{ step: '4', title: 'CFS Destination', desc: 'Dégroupage' },
|
|
||||||
{ step: '5', title: 'Livraison', desc: 'Livraison finale' },
|
|
||||||
].map((item, index) => (
|
|
||||||
<div key={item.step} className="flex flex-col md:flex-row items-center">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="w-10 h-10 bg-blue-600 text-white rounded-full flex items-center justify-center font-bold mb-2">
|
|
||||||
{item.step}
|
|
||||||
</div>
|
|
||||||
<p className="font-medium text-sm">{item.title}</p>
|
|
||||||
<p className="text-xs text-gray-500">{item.desc}</p>
|
|
||||||
</div>
|
|
||||||
{index < 4 && (
|
|
||||||
<div className="hidden md:block text-gray-400 mx-2">→</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Tips */}
|
|
||||||
<Card className="mt-8 bg-amber-50 border-amber-200">
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<h3 className="font-semibold text-amber-900 mb-3">Conseils Pratiques</h3>
|
|
||||||
<ul className="list-disc list-inside space-y-2 text-amber-800">
|
|
||||||
<li>Demandez toujours des cotations LCL ET FCL pour comparer</li>
|
|
||||||
<li>En LCL, emballez solidement car vos marchandises seront manipulées plusieurs fois</li>
|
|
||||||
<li>Vérifiez les frais additionnels (CFS, manutention) qui peuvent surprendre en LCL</li>
|
|
||||||
<li>Un conteneur 40' n'est pas le double du prix d'un 20' - parfois 20-30% plus cher seulement</li>
|
|
||||||
</ul>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,298 +0,0 @@
|
|||||||
/**
|
|
||||||
* Lettre de Crédit (L/C) - Wiki Page
|
|
||||||
*
|
|
||||||
* Instrument de paiement international sécurisé
|
|
||||||
*/
|
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
|
||||||
|
|
||||||
const typesLC = [
|
|
||||||
{
|
|
||||||
type: 'Irrévocable',
|
|
||||||
description: 'Ne peut être modifiée ou annulée sans l\'accord de toutes les parties',
|
|
||||||
usage: 'Standard depuis UCP 600',
|
|
||||||
recommended: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'Confirmée',
|
|
||||||
description: 'Une banque (généralement dans le pays du vendeur) ajoute sa garantie',
|
|
||||||
usage: 'Recommandé pour les pays à risque',
|
|
||||||
recommended: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'Transférable',
|
|
||||||
description: 'Peut être transférée à un second bénéficiaire (sous-traitant)',
|
|
||||||
usage: 'Négoce, intermédiaires',
|
|
||||||
recommended: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'Revolving',
|
|
||||||
description: 'Se reconstitue automatiquement après chaque utilisation',
|
|
||||||
usage: 'Commandes répétitives',
|
|
||||||
recommended: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'Stand-by',
|
|
||||||
description: 'Garantie de paiement en cas de défaillance (rarement utilisée)',
|
|
||||||
usage: 'Garantie bancaire',
|
|
||||||
recommended: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'Red/Green Clause',
|
|
||||||
description: 'Permet un paiement anticipé avant expédition',
|
|
||||||
usage: 'Préfinancement du vendeur',
|
|
||||||
recommended: false,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const parties = [
|
|
||||||
{ role: 'Donneur d\'ordre (Applicant)', description: 'L\'acheteur/importateur qui demande l\'ouverture de la L/C' },
|
|
||||||
{ role: 'Bénéficiaire', description: 'Le vendeur/exportateur qui recevra le paiement' },
|
|
||||||
{ role: 'Banque émettrice', description: 'Banque de l\'acheteur qui émet la L/C' },
|
|
||||||
{ role: 'Banque notificatrice', description: 'Banque du vendeur qui notifie la L/C (sans engagement)' },
|
|
||||||
{ role: 'Banque confirmatrice', description: 'Banque qui ajoute sa propre garantie (optionnel)' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const documentsTypiques = [
|
|
||||||
{ document: 'Facture commerciale', obligatoire: true },
|
|
||||||
{ document: 'Bill of Lading (connaissement)', obligatoire: true },
|
|
||||||
{ document: 'Liste de colisage', obligatoire: true },
|
|
||||||
{ document: 'Certificat d\'origine', obligatoire: false },
|
|
||||||
{ document: 'Certificat d\'assurance', obligatoire: false },
|
|
||||||
{ document: 'Certificat d\'inspection', obligatoire: false },
|
|
||||||
{ document: 'Certificat phytosanitaire', obligatoire: false },
|
|
||||||
];
|
|
||||||
|
|
||||||
const erreursFrequentes = [
|
|
||||||
'Nom du bénéficiaire mal orthographié',
|
|
||||||
'Dates d\'expédition ou d\'expiration dépassées',
|
|
||||||
'Description des marchandises ne correspondant pas exactement',
|
|
||||||
'Documents présentés en retard (au-delà du délai de présentation)',
|
|
||||||
'Incoterm incohérent avec les documents',
|
|
||||||
'Montant des documents différent de la L/C',
|
|
||||||
'Connaissement avec réserves (claused B/L)',
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function LettreCreditPage() {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Header with back link */}
|
|
||||||
<div className="flex items-center gap-4 mb-6">
|
|
||||||
<Link
|
|
||||||
href="/dashboard/wiki"
|
|
||||||
className="flex items-center text-blue-600 hover:text-blue-800 transition-colors"
|
|
||||||
>
|
|
||||||
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
|
||||||
</svg>
|
|
||||||
Retour au Wiki
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Title */}
|
|
||||||
<div className="mb-8">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="text-4xl">💳</span>
|
|
||||||
<h1 className="text-3xl font-bold text-gray-900">Lettre de Crédit (L/C)</h1>
|
|
||||||
</div>
|
|
||||||
<p className="mt-3 text-gray-600 max-w-3xl">
|
|
||||||
La lettre de crédit documentaire (L/C ou Documentary Credit) est un engagement de paiement
|
|
||||||
émis par une banque pour le compte de l'acheteur, garantissant au vendeur d'être payé
|
|
||||||
contre présentation de documents conformes. Elle est régie par les Règles UCP 600 de la CCI.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* How it works */}
|
|
||||||
<Card className="bg-blue-50 border-blue-200">
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<h3 className="font-semibold text-blue-900 mb-3">🔄 Fonctionnement Simplifié</h3>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-2">
|
|
||||||
{[
|
|
||||||
{ step: '1', title: 'Demande', desc: 'Acheteur demande l\'ouverture à sa banque' },
|
|
||||||
{ step: '2', title: 'Émission', desc: 'Banque émettrice envoie la L/C' },
|
|
||||||
{ step: '3', title: 'Notification', desc: 'Banque du vendeur notifie la L/C' },
|
|
||||||
{ step: '4', title: 'Expédition', desc: 'Vendeur expédie et présente les documents' },
|
|
||||||
{ step: '5', title: 'Paiement', desc: 'Banque paie après vérification' },
|
|
||||||
].map((item) => (
|
|
||||||
<div key={item.step} className="text-center">
|
|
||||||
<div className="w-10 h-10 bg-blue-600 text-white rounded-full flex items-center justify-center mx-auto font-bold">
|
|
||||||
{item.step}
|
|
||||||
</div>
|
|
||||||
<p className="font-medium text-blue-900 mt-2">{item.title}</p>
|
|
||||||
<p className="text-xs text-blue-700">{item.desc}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Parties */}
|
|
||||||
<div className="mt-8">
|
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-4">👥 Parties Impliquées</h2>
|
|
||||||
<Card className="bg-white">
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<div className="space-y-4">
|
|
||||||
{parties.map((p) => (
|
|
||||||
<div key={p.role} className="flex items-start gap-3 pb-4 border-b last:border-0">
|
|
||||||
<span className="mt-1 w-3 h-3 bg-blue-500 rounded-full flex-shrink-0"></span>
|
|
||||||
<div>
|
|
||||||
<h4 className="font-medium text-gray-900">{p.role}</h4>
|
|
||||||
<p className="text-sm text-gray-600">{p.description}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Types */}
|
|
||||||
<div className="mt-8">
|
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-4">📋 Types de Lettres de Crédit</h2>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
{typesLC.map((lc) => (
|
|
||||||
<Card key={lc.type} className={`bg-white ${lc.recommended ? 'border-green-300 border-2' : ''}`}>
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<span className="text-lg">{lc.type}</span>
|
|
||||||
{lc.recommended && (
|
|
||||||
<span className="px-2 py-1 text-xs bg-green-100 text-green-700 rounded">Recommandé</span>
|
|
||||||
)}
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-gray-600 text-sm">{lc.description}</p>
|
|
||||||
<p className="text-xs text-gray-500 mt-2">Usage : {lc.usage}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Documents */}
|
|
||||||
<div className="mt-8">
|
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-4">📄 Documents Typiquement Requis</h2>
|
|
||||||
<Card className="bg-white">
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
{documentsTypiques.map((doc) => (
|
|
||||||
<div key={doc.document} className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg">
|
|
||||||
<span className={`px-2 py-1 text-xs rounded ${doc.obligatoire ? 'bg-red-100 text-red-700' : 'bg-gray-200 text-gray-700'}`}>
|
|
||||||
{doc.obligatoire ? 'Toujours' : 'Selon L/C'}
|
|
||||||
</span>
|
|
||||||
<span className="text-gray-900">{doc.document}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Key Dates */}
|
|
||||||
<Card className="mt-8 bg-gray-50">
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<h3 className="font-semibold text-gray-900 mb-3">📅 Dates Clés à Surveiller</h3>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
||||||
<div className="bg-white p-4 rounded-lg border">
|
|
||||||
<h4 className="font-medium text-gray-900">Date d'expédition</h4>
|
|
||||||
<p className="text-sm text-gray-600">
|
|
||||||
Date limite pour expédier les marchandises (Latest Shipment Date)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white p-4 rounded-lg border">
|
|
||||||
<h4 className="font-medium text-gray-900">Délai de présentation</h4>
|
|
||||||
<p className="text-sm text-gray-600">
|
|
||||||
Généralement 21 jours après expédition pour présenter les documents
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white p-4 rounded-lg border">
|
|
||||||
<h4 className="font-medium text-gray-900">Date de validité</h4>
|
|
||||||
<p className="text-sm text-gray-600">
|
|
||||||
Date limite absolue de la L/C (Expiry Date)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Costs */}
|
|
||||||
<Card className="mt-8 bg-gray-50">
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<h3 className="font-semibold text-gray-900 mb-3">💰 Coûts Typiques</h3>
|
|
||||||
<div className="bg-white p-4 rounded-lg border">
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
|
||||||
<div>
|
|
||||||
<h4 className="font-medium">Côté Acheteur</h4>
|
|
||||||
<ul className="text-gray-600 mt-2 space-y-1">
|
|
||||||
<li>• Commission d'ouverture : 0.1% - 0.5%</li>
|
|
||||||
<li>• Frais de modification : fixes</li>
|
|
||||||
<li>• Commission d'engagement : si non utilisée</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 className="font-medium">Côté Vendeur</h4>
|
|
||||||
<ul className="text-gray-600 mt-2 space-y-1">
|
|
||||||
<li>• Frais de notification : fixes</li>
|
|
||||||
<li>• Commission de confirmation : 0.1% - 2%+</li>
|
|
||||||
<li>• Frais de négociation : variable</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-gray-500 mt-3">
|
|
||||||
Note : Les frais de confirmation peuvent être très élevés pour les pays à risque.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Common Errors */}
|
|
||||||
<Card className="mt-8 bg-red-50 border-red-200">
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<h3 className="font-semibold text-red-900 mb-3">⚠️ Erreurs Fréquentes (Réserves)</h3>
|
|
||||||
<p className="text-red-800 mb-3">
|
|
||||||
Ces erreurs entraînent des "réserves" de la banque et peuvent bloquer le paiement :
|
|
||||||
</p>
|
|
||||||
<ul className="list-disc list-inside space-y-1 text-red-700">
|
|
||||||
{erreursFrequentes.map((err) => (
|
|
||||||
<li key={err}>{err}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* UCP 600 */}
|
|
||||||
<Card className="mt-8 bg-gray-50">
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<h3 className="font-semibold text-gray-900 mb-3">📜 Règles UCP 600</h3>
|
|
||||||
<p className="text-gray-600">
|
|
||||||
Les Règles et Usances Uniformes (UCP 600) de la Chambre de Commerce Internationale (CCI)
|
|
||||||
régissent les lettres de crédit documentaires depuis 2007. Points clés :
|
|
||||||
</p>
|
|
||||||
<ul className="list-disc list-inside space-y-1 text-gray-700 mt-3">
|
|
||||||
<li>Délai standard d'examen des documents : 5 jours ouvrés bancaires</li>
|
|
||||||
<li>La L/C est irrévocable par défaut (même si non mentionné)</li>
|
|
||||||
<li>Les banques examinent les documents, pas les marchandises</li>
|
|
||||||
<li>Les documents doivent être "conformes en apparence"</li>
|
|
||||||
</ul>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Tips */}
|
|
||||||
<Card className="mt-8 bg-amber-50 border-amber-200">
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<h3 className="font-semibold text-amber-900 mb-3">💡 Conseils Pratiques</h3>
|
|
||||||
<ul className="list-disc list-inside space-y-2 text-amber-800">
|
|
||||||
<li>Vérifier minutieusement les termes de la L/C dès réception</li>
|
|
||||||
<li>Demander des modifications AVANT expédition si nécessaire</li>
|
|
||||||
<li>Préparer les documents exactement comme demandé (virgules, orthographe)</li>
|
|
||||||
<li>Respecter les délais avec marge de sécurité</li>
|
|
||||||
<li>Travailler avec un transitaire expérimenté en documentaire</li>
|
|
||||||
<li>Conserver des copies de tous les documents</li>
|
|
||||||
<li>Envisager une L/C confirmée pour les nouveaux clients ou pays risqués</li>
|
|
||||||
</ul>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,149 +0,0 @@
|
|||||||
/**
|
|
||||||
* Wiki Page - Maritime Import/Export Knowledge Base
|
|
||||||
*
|
|
||||||
* Main page displaying cards for all wiki topics
|
|
||||||
*/
|
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card';
|
|
||||||
|
|
||||||
const wikiTopics = [
|
|
||||||
{
|
|
||||||
title: 'Incoterms 2020',
|
|
||||||
description: 'Les règles internationales pour l\'interprétation des termes commerciaux',
|
|
||||||
icon: '📜',
|
|
||||||
href: '/dashboard/wiki/incoterms',
|
|
||||||
tags: ['FOB', 'CIF', 'EXW', 'DDP'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Documents de Transport',
|
|
||||||
description: 'Les documents essentiels pour le transport maritime',
|
|
||||||
icon: '📋',
|
|
||||||
href: '/dashboard/wiki/documents-transport',
|
|
||||||
tags: ['B/L', 'Sea Waybill', 'Manifest'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Conteneurs et Types de Cargo',
|
|
||||||
description: 'Guide complet des types de conteneurs maritimes',
|
|
||||||
icon: '📦',
|
|
||||||
href: '/dashboard/wiki/conteneurs',
|
|
||||||
tags: ['20\'', '40\'', 'Reefer', 'Open Top'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'LCL vs FCL',
|
|
||||||
description: 'Différences entre groupage et conteneur complet',
|
|
||||||
icon: '⚖️',
|
|
||||||
href: '/dashboard/wiki/lcl-vs-fcl',
|
|
||||||
tags: ['Groupage', 'Complet', 'Coûts'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Procédures Douanières',
|
|
||||||
description: 'Guide des formalités douanières import/export',
|
|
||||||
icon: '🛃',
|
|
||||||
href: '/dashboard/wiki/douanes',
|
|
||||||
tags: ['Déclaration', 'Tarifs', 'Régimes'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Assurance Maritime',
|
|
||||||
description: 'Protection des marchandises en transit',
|
|
||||||
icon: '🛡️',
|
|
||||||
href: '/dashboard/wiki/assurance',
|
|
||||||
tags: ['ICC A', 'ICC B', 'ICC C'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Calcul du Fret Maritime',
|
|
||||||
description: 'Comment sont calculés les coûts de transport',
|
|
||||||
icon: '🧮',
|
|
||||||
href: '/dashboard/wiki/calcul-fret',
|
|
||||||
tags: ['CBM', 'THC', 'BAF', 'CAF'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Ports et Routes Maritimes',
|
|
||||||
description: 'Les principales routes commerciales mondiales',
|
|
||||||
icon: '🌍',
|
|
||||||
href: '/dashboard/wiki/ports-routes',
|
|
||||||
tags: ['Hub', 'Détroits', 'Canaux'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'VGM (Verified Gross Mass)',
|
|
||||||
description: 'Obligation de pesée des conteneurs (SOLAS)',
|
|
||||||
icon: '⚓',
|
|
||||||
href: '/dashboard/wiki/vgm',
|
|
||||||
tags: ['SOLAS', 'Pesée', 'Certification'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Marchandises Dangereuses (IMDG)',
|
|
||||||
description: 'Transport de matières dangereuses par mer',
|
|
||||||
icon: '⚠️',
|
|
||||||
href: '/dashboard/wiki/imdg',
|
|
||||||
tags: ['Classes', 'Étiquetage', 'Sécurité'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Lettre de Crédit (L/C)',
|
|
||||||
description: 'Instrument de paiement international sécurisé',
|
|
||||||
icon: '💳',
|
|
||||||
href: '/dashboard/wiki/lettre-credit',
|
|
||||||
tags: ['Banque', 'Paiement', 'Sécurité'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Transit Time et Délais',
|
|
||||||
description: 'Comprendre les délais en transport maritime',
|
|
||||||
icon: '⏱️',
|
|
||||||
href: '/dashboard/wiki/transit-time',
|
|
||||||
tags: ['Cut-off', 'Free time', 'Demurrage'],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function WikiPage() {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="mb-8">
|
|
||||||
<h1 className="text-3xl font-bold text-gray-900">Wiki Maritime</h1>
|
|
||||||
<p className="mt-2 text-gray-600">
|
|
||||||
Base de connaissances sur l'import/export maritime. Cliquez sur un sujet pour en savoir plus.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Cards Grid */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
||||||
{wikiTopics.map((topic) => (
|
|
||||||
<Link key={topic.href} href={topic.href} className="block group">
|
|
||||||
<Card className="h-full transition-all duration-200 hover:shadow-lg hover:border-blue-300 bg-white">
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<span className="text-4xl">{topic.icon}</span>
|
|
||||||
</div>
|
|
||||||
<CardTitle className="text-xl mt-3 group-hover:text-blue-600 transition-colors">
|
|
||||||
{topic.title}
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription className="text-gray-600">
|
|
||||||
{topic.description}
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{topic.tags.map((tag) => (
|
|
||||||
<span
|
|
||||||
key={tag}
|
|
||||||
className="px-2 py-1 text-xs font-medium bg-blue-50 text-blue-700 rounded-full"
|
|
||||||
>
|
|
||||||
{tag}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer info */}
|
|
||||||
<div className="mt-8 p-4 bg-blue-50 rounded-lg">
|
|
||||||
<p className="text-sm text-blue-800">
|
|
||||||
<span className="font-semibold">Besoin d'aide ?</span> Ces guides sont régulièrement mis à jour avec les dernières réglementations et bonnes pratiques du secteur maritime.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,301 +0,0 @@
|
|||||||
/**
|
|
||||||
* Ports et Routes Maritimes - Wiki Page
|
|
||||||
*
|
|
||||||
* Les principales routes commerciales mondiales
|
|
||||||
*/
|
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
|
||||||
|
|
||||||
const majorRoutes = [
|
|
||||||
{
|
|
||||||
name: 'Asie - Europe',
|
|
||||||
description: 'La plus importante route commerciale mondiale',
|
|
||||||
transitTime: '28-35 jours',
|
|
||||||
volume: '~24 millions TEU/an',
|
|
||||||
keyPorts: ['Shanghai', 'Singapour', 'Rotterdam', 'Hambourg', 'Anvers'],
|
|
||||||
passages: ['Canal de Suez', 'Détroit de Malacca'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Asie - Amérique du Nord (Pacifique)',
|
|
||||||
description: 'Deuxième route la plus importante',
|
|
||||||
transitTime: '12-18 jours',
|
|
||||||
volume: '~26 millions TEU/an',
|
|
||||||
keyPorts: ['Shanghai', 'Busan', 'Los Angeles', 'Long Beach', 'Seattle'],
|
|
||||||
passages: ['Traversée Pacifique directe'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Europe - Amérique du Nord (Atlantique)',
|
|
||||||
description: 'Route transatlantique historique',
|
|
||||||
transitTime: '8-12 jours',
|
|
||||||
volume: '~8 millions TEU/an',
|
|
||||||
keyPorts: ['Rotterdam', 'Anvers', 'New York', 'Savannah', 'Charleston'],
|
|
||||||
passages: ['Traversée Atlantique directe'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Asie - Méditerranée',
|
|
||||||
description: 'Variante de la route Asie-Europe via Med',
|
|
||||||
transitTime: '18-25 jours',
|
|
||||||
volume: '~6 millions TEU/an',
|
|
||||||
keyPorts: ['Shanghai', 'Pirée', 'Gênes', 'Barcelone', 'Tanger Med'],
|
|
||||||
passages: ['Canal de Suez', 'Détroit de Gibraltar'],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const strategicPassages = [
|
|
||||||
{
|
|
||||||
name: 'Canal de Suez',
|
|
||||||
location: 'Égypte',
|
|
||||||
opened: '1869',
|
|
||||||
length: '193.3 km',
|
|
||||||
transitTime: '12-16 heures',
|
|
||||||
traffic: '~20,000 navires/an',
|
|
||||||
importance: 'Relie la Méditerranée à la Mer Rouge. Raccourcit de 7,000 km la route Europe-Asie.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Canal de Panama',
|
|
||||||
location: 'Panama',
|
|
||||||
opened: '1914',
|
|
||||||
length: '82 km',
|
|
||||||
transitTime: '8-10 heures',
|
|
||||||
traffic: '~14,000 navires/an',
|
|
||||||
importance: 'Relie l\'Atlantique au Pacifique. Crucial pour le commerce Asie-Côte Est USA.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Détroit de Malacca',
|
|
||||||
location: 'Malaisie/Indonésie',
|
|
||||||
opened: 'Naturel',
|
|
||||||
length: '800 km',
|
|
||||||
transitTime: '12 heures',
|
|
||||||
traffic: '~90,000 navires/an',
|
|
||||||
importance: 'Point de passage obligé entre Océan Indien et Mer de Chine. 25% du commerce mondial.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Détroit de Gibraltar',
|
|
||||||
location: 'Espagne/Maroc',
|
|
||||||
opened: 'Naturel',
|
|
||||||
length: '60 km',
|
|
||||||
transitTime: '2-3 heures',
|
|
||||||
traffic: '~100,000 navires/an',
|
|
||||||
importance: 'Entrée en Méditerranée depuis l\'Atlantique.',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const majorPorts = [
|
|
||||||
{ name: 'Shanghai', country: 'Chine', volume: '47.0', rank: 1 },
|
|
||||||
{ name: 'Singapour', country: 'Singapour', volume: '37.2', rank: 2 },
|
|
||||||
{ name: 'Ningbo-Zhoushan', country: 'Chine', volume: '33.0', rank: 3 },
|
|
||||||
{ name: 'Shenzhen', country: 'Chine', volume: '30.0', rank: 4 },
|
|
||||||
{ name: 'Guangzhou', country: 'Chine', volume: '24.2', rank: 5 },
|
|
||||||
{ name: 'Busan', country: 'Corée du Sud', volume: '22.7', rank: 6 },
|
|
||||||
{ name: 'Qingdao', country: 'Chine', volume: '22.0', rank: 7 },
|
|
||||||
{ name: 'Rotterdam', country: 'Pays-Bas', volume: '14.5', rank: 8 },
|
|
||||||
{ name: 'Dubai/Jebel Ali', country: 'EAU', volume: '14.1', rank: 9 },
|
|
||||||
{ name: 'Tianjin', country: 'Chine', volume: '14.0', rank: 10 },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function PortsRoutesPage() {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Header with back link */}
|
|
||||||
<div className="flex items-center gap-4 mb-6">
|
|
||||||
<Link
|
|
||||||
href="/dashboard/wiki"
|
|
||||||
className="flex items-center text-blue-600 hover:text-blue-800 transition-colors"
|
|
||||||
>
|
|
||||||
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
|
||||||
</svg>
|
|
||||||
Retour au Wiki
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Title */}
|
|
||||||
<div className="mb-8">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="text-4xl">🌍</span>
|
|
||||||
<h1 className="text-3xl font-bold text-gray-900">Ports et Routes Maritimes</h1>
|
|
||||||
</div>
|
|
||||||
<p className="mt-3 text-gray-600 max-w-3xl">
|
|
||||||
Le transport maritime assure plus de 80% du commerce mondial en volume.
|
|
||||||
Comprendre les grandes routes et les ports stratégiques est essentiel pour optimiser sa supply chain.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Key Stats */}
|
|
||||||
<Card className="bg-blue-50 border-blue-200">
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<h3 className="font-semibold text-blue-900 mb-3">📊 Chiffres Clés du Maritime Mondial</h3>
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
||||||
<div className="text-center">
|
|
||||||
<p className="text-3xl font-bold text-blue-700">80%</p>
|
|
||||||
<p className="text-sm text-blue-600">du commerce mondial (volume)</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<p className="text-3xl font-bold text-blue-700">~800M</p>
|
|
||||||
<p className="text-sm text-blue-600">TEU transportés/an</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<p className="text-3xl font-bold text-blue-700">~60,000</p>
|
|
||||||
<p className="text-sm text-blue-600">navires marchands</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<p className="text-3xl font-bold text-blue-700">~14B</p>
|
|
||||||
<p className="text-sm text-blue-600">tonnes de marchandises/an</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Major Routes */}
|
|
||||||
<div className="mt-8">
|
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-4">🛳️ Routes Commerciales Majeures</h2>
|
|
||||||
<div className="space-y-4">
|
|
||||||
{majorRoutes.map((route) => (
|
|
||||||
<Card key={route.name} className="bg-white">
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle className="text-lg">{route.name}</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-gray-600 mb-3">{route.description}</p>
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
|
||||||
<div>
|
|
||||||
<span className="text-gray-500">Transit time:</span>
|
|
||||||
<p className="font-medium">{route.transitTime}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-gray-500">Volume annuel:</span>
|
|
||||||
<p className="font-medium">{route.volume}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-gray-500">Ports clés:</span>
|
|
||||||
<p className="font-medium">{route.keyPorts.slice(0, 3).join(', ')}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-gray-500">Passages:</span>
|
|
||||||
<p className="font-medium">{route.passages.join(', ')}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Strategic Passages */}
|
|
||||||
<div className="mt-8">
|
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-4">⚓ Passages Stratégiques</h2>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
{strategicPassages.map((passage) => (
|
|
||||||
<Card key={passage.name} className="bg-white">
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<span className="text-lg">{passage.name}</span>
|
|
||||||
<span className="text-xs bg-gray-100 px-2 py-1 rounded">{passage.location}</span>
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-gray-600 text-sm mb-3">{passage.importance}</p>
|
|
||||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
|
||||||
<div className="bg-gray-50 p-2 rounded">
|
|
||||||
<span className="text-gray-500">Longueur:</span>
|
|
||||||
<p className="font-medium">{passage.length}</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-gray-50 p-2 rounded">
|
|
||||||
<span className="text-gray-500">Transit:</span>
|
|
||||||
<p className="font-medium">{passage.transitTime}</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-gray-50 p-2 rounded">
|
|
||||||
<span className="text-gray-500">Trafic:</span>
|
|
||||||
<p className="font-medium">{passage.traffic}</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-gray-50 p-2 rounded">
|
|
||||||
<span className="text-gray-500">Ouverture:</span>
|
|
||||||
<p className="font-medium">{passage.opened}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Top Ports */}
|
|
||||||
<div className="mt-8">
|
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-4">🏆 Top 10 Ports Mondiaux (TEU)</h2>
|
|
||||||
<Card className="bg-white">
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b">
|
|
||||||
<th className="text-left py-2 font-medium">Rang</th>
|
|
||||||
<th className="text-left py-2 font-medium">Port</th>
|
|
||||||
<th className="text-left py-2 font-medium">Pays</th>
|
|
||||||
<th className="text-right py-2 font-medium">Volume (M TEU)</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{majorPorts.map((port) => (
|
|
||||||
<tr key={port.name} className="border-b last:border-0 hover:bg-gray-50">
|
|
||||||
<td className="py-3">
|
|
||||||
<span className={`inline-block w-6 h-6 text-center rounded-full text-white text-xs leading-6 ${
|
|
||||||
port.rank <= 3 ? 'bg-yellow-500' : 'bg-gray-400'
|
|
||||||
}`}>
|
|
||||||
{port.rank}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="py-3 font-medium text-gray-900">{port.name}</td>
|
|
||||||
<td className="py-3 text-gray-600">{port.country}</td>
|
|
||||||
<td className="py-3 text-right font-mono">{port.volume}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-gray-500 mt-3">Source: World Shipping Council, données approximatives</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Hub Ports Info */}
|
|
||||||
<Card className="mt-8 bg-gray-50">
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<h3 className="font-semibold text-gray-900 mb-3">🔄 Ports Hub vs Ports Régionaux</h3>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div className="bg-white p-4 rounded-lg border">
|
|
||||||
<h4 className="font-medium text-gray-900">Ports Hub (Transbordement)</h4>
|
|
||||||
<p className="text-sm text-gray-600 mt-1">
|
|
||||||
Grands ports où les conteneurs sont transférés entre navires mères et feeders.
|
|
||||||
Ex: Singapour, Tanger Med, Algésiras.
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-gray-500 mt-2">Avantage: Desserte mondiale, fréquence élevée</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white p-4 rounded-lg border">
|
|
||||||
<h4 className="font-medium text-gray-900">Ports Régionaux (Gateway)</h4>
|
|
||||||
<p className="text-sm text-gray-600 mt-1">
|
|
||||||
Ports desservant directement un hinterland économique.
|
|
||||||
Ex: Le Havre, Marseille, Hambourg.
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-gray-500 mt-2">Avantage: Proximité du marché final, moins de manutention</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Tips */}
|
|
||||||
<Card className="mt-8 bg-amber-50 border-amber-200">
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<h3 className="font-semibold text-amber-900 mb-3">💡 Conseils Pratiques</h3>
|
|
||||||
<ul className="list-disc list-inside space-y-2 text-amber-800">
|
|
||||||
<li>Privilégiez les routes directes pour réduire les délais et risques</li>
|
|
||||||
<li>Anticipez les congestions portuaires (Los Angeles, Rotterdam en haute saison)</li>
|
|
||||||
<li>Surveillez les perturbations géopolitiques (Canal de Suez, Détroit d'Ormuz)</li>
|
|
||||||
<li>Comparez les transbordements vs les services directs selon vos priorités (coût vs délai)</li>
|
|
||||||
<li>Vérifiez les connexions ferroviaires/fluviales depuis les ports</li>
|
|
||||||
</ul>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,354 +0,0 @@
|
|||||||
/**
|
|
||||||
* Transit Time et Délais - Wiki Page
|
|
||||||
*
|
|
||||||
* Comprendre les délais en transport maritime
|
|
||||||
*/
|
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
|
||||||
|
|
||||||
const etapesTimeline = [
|
|
||||||
{
|
|
||||||
etape: 'Booking',
|
|
||||||
description: 'Réservation de l\'espace sur le navire',
|
|
||||||
delai: '1-7 jours avant cut-off',
|
|
||||||
responsable: 'Transitaire/Exportateur',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
etape: 'Container pickup',
|
|
||||||
description: 'Enlèvement du conteneur vide au dépôt',
|
|
||||||
delai: '2-5 jours avant cut-off',
|
|
||||||
responsable: 'Transporteur terrestre',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
etape: 'Empotage (Stuffing)',
|
|
||||||
description: 'Chargement des marchandises dans le conteneur',
|
|
||||||
delai: '1-3 jours avant cut-off',
|
|
||||||
responsable: 'Exportateur',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
etape: 'Documentation cut-off',
|
|
||||||
description: 'Date limite pour soumettre les documents (B/L, VGM)',
|
|
||||||
delai: '24-48h avant ETD',
|
|
||||||
responsable: 'Transitaire',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
etape: 'Cargo cut-off',
|
|
||||||
description: 'Date limite de dépôt du conteneur au terminal',
|
|
||||||
delai: '24-48h avant ETD',
|
|
||||||
responsable: 'Transporteur terrestre',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
etape: 'ETD (Estimated Time of Departure)',
|
|
||||||
description: 'Départ estimé du navire du port d\'origine',
|
|
||||||
delai: 'Jour J',
|
|
||||||
responsable: 'Compagnie maritime',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
etape: 'Transit maritime',
|
|
||||||
description: 'Traversée maritime (variable selon route)',
|
|
||||||
delai: '10-45 jours',
|
|
||||||
responsable: 'Compagnie maritime',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
etape: 'ETA (Estimated Time of Arrival)',
|
|
||||||
description: 'Arrivée estimée au port de destination',
|
|
||||||
delai: 'Jour J + transit',
|
|
||||||
responsable: 'Compagnie maritime',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
etape: 'Déchargement',
|
|
||||||
description: 'Déchargement du navire et mise à quai',
|
|
||||||
delai: '1-3 jours après ETA',
|
|
||||||
responsable: 'Terminal portuaire',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
etape: 'Dédouanement',
|
|
||||||
description: 'Formalités douanières à destination',
|
|
||||||
delai: '1-5 jours',
|
|
||||||
responsable: 'Commissionnaire en douane',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
etape: 'Livraison',
|
|
||||||
description: 'Acheminement final au destinataire',
|
|
||||||
delai: '1-5 jours',
|
|
||||||
responsable: 'Transporteur terrestre',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const fraisRetard = [
|
|
||||||
{
|
|
||||||
nom: 'Demurrage',
|
|
||||||
definition: 'Frais pour le conteneur resté au terminal au-delà du free time',
|
|
||||||
taux: '50-150 USD/jour/conteneur',
|
|
||||||
lieu: 'Terminal portuaire',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
nom: 'Detention',
|
|
||||||
definition: 'Frais pour le conteneur gardé hors terminal au-delà du free time',
|
|
||||||
taux: '30-100 USD/jour/conteneur',
|
|
||||||
lieu: 'Chez l\'importateur',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
nom: 'Storage',
|
|
||||||
definition: 'Frais de stockage au terminal (séparés du demurrage)',
|
|
||||||
taux: 'Variable selon port',
|
|
||||||
lieu: 'Terminal portuaire',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
nom: 'Per Diem',
|
|
||||||
definition: 'Frais journaliers combinés (parfois utilisé pour demurrage+detention)',
|
|
||||||
taux: '50-200 USD/jour',
|
|
||||||
lieu: 'Variable',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const transitTimes = [
|
|
||||||
{ route: 'Shanghai → Rotterdam', temps: '28-32 jours', via: 'Suez' },
|
|
||||||
{ route: 'Shanghai → Le Havre', temps: '30-35 jours', via: 'Suez' },
|
|
||||||
{ route: 'Shanghai → Los Angeles', temps: '12-15 jours', via: 'Direct Pacifique' },
|
|
||||||
{ route: 'Shanghai → New York', temps: '35-40 jours', via: 'Suez ou Panama' },
|
|
||||||
{ route: 'Rotterdam → New York', temps: '10-14 jours', via: 'Direct Atlantique' },
|
|
||||||
{ route: 'Mumbai → Rotterdam', temps: '18-22 jours', via: 'Suez' },
|
|
||||||
{ route: 'Santos → Rotterdam', temps: '18-22 jours', via: 'Direct Atlantique' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function TransitTimePage() {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Header with back link */}
|
|
||||||
<div className="flex items-center gap-4 mb-6">
|
|
||||||
<Link
|
|
||||||
href="/dashboard/wiki"
|
|
||||||
className="flex items-center text-blue-600 hover:text-blue-800 transition-colors"
|
|
||||||
>
|
|
||||||
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
|
||||||
</svg>
|
|
||||||
Retour au Wiki
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Title */}
|
|
||||||
<div className="mb-8">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="text-4xl">⏱️</span>
|
|
||||||
<h1 className="text-3xl font-bold text-gray-900">Transit Time et Délais</h1>
|
|
||||||
</div>
|
|
||||||
<p className="mt-3 text-gray-600 max-w-3xl">
|
|
||||||
La gestion des délais est cruciale en transport maritime. Comprendre les différentes étapes,
|
|
||||||
les cut-off dates et les frais de retard permet d'optimiser sa supply chain et d'éviter
|
|
||||||
les coûts supplémentaires.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Key Terms */}
|
|
||||||
<Card className="bg-blue-50 border-blue-200">
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<h3 className="font-semibold text-blue-900 mb-3">📖 Termes Clés</h3>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
||||||
<div>
|
|
||||||
<h4 className="font-medium text-blue-800">ETD</h4>
|
|
||||||
<p className="text-sm text-blue-700">Estimated Time of Departure - Départ estimé</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 className="font-medium text-blue-800">ETA</h4>
|
|
||||||
<p className="text-sm text-blue-700">Estimated Time of Arrival - Arrivée estimée</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 className="font-medium text-blue-800">Cut-off</h4>
|
|
||||||
<p className="text-sm text-blue-700">Date/heure limite de dépôt</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 className="font-medium text-blue-800">Free time</h4>
|
|
||||||
<p className="text-sm text-blue-700">Jours gratuits avant frais de retard</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Timeline */}
|
|
||||||
<div className="mt-8">
|
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-4">📅 Timeline d'une Expédition FCL</h2>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{etapesTimeline.map((item, index) => (
|
|
||||||
<Card key={item.etape} className={`bg-white ${index === 5 || index === 7 ? 'border-blue-300 border-2' : ''}`}>
|
|
||||||
<CardContent className="py-4">
|
|
||||||
<div className="flex items-start gap-4">
|
|
||||||
<div className="flex-shrink-0 w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center text-blue-600 font-bold text-sm">
|
|
||||||
{index + 1}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex flex-wrap items-center gap-2 mb-1">
|
|
||||||
<h4 className="font-medium text-gray-900">{item.etape}</h4>
|
|
||||||
<span className="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded">{item.delai}</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-gray-600">{item.description}</p>
|
|
||||||
<p className="text-xs text-gray-400 mt-1">Responsable : {item.responsable}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Transit Times */}
|
|
||||||
<div className="mt-8">
|
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-4">🚢 Transit Times Indicatifs</h2>
|
|
||||||
<Card className="bg-white">
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b">
|
|
||||||
<th className="text-left py-2 font-medium">Route</th>
|
|
||||||
<th className="text-center py-2 font-medium">Transit Time</th>
|
|
||||||
<th className="text-right py-2 font-medium">Via</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{transitTimes.map((tt) => (
|
|
||||||
<tr key={tt.route} className="border-b last:border-0 hover:bg-gray-50">
|
|
||||||
<td className="py-3 text-gray-900">{tt.route}</td>
|
|
||||||
<td className="py-3 text-center font-mono text-blue-600">{tt.temps}</td>
|
|
||||||
<td className="py-3 text-right text-gray-500">{tt.via}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-gray-500 mt-3">
|
|
||||||
Note : Ces temps sont indicatifs et varient selon les rotations, transbordements et conditions.
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Free Time */}
|
|
||||||
<Card className="mt-8 bg-gray-50">
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<h3 className="font-semibold text-gray-900 mb-3">⏰ Free Time (Jours Gratuits)</h3>
|
|
||||||
<p className="text-gray-600 mb-4">
|
|
||||||
Période pendant laquelle le conteneur peut rester au terminal ou chez l'importateur
|
|
||||||
sans frais supplémentaires.
|
|
||||||
</p>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
||||||
<div className="bg-white p-4 rounded-lg border">
|
|
||||||
<h4 className="font-medium text-gray-900">Free time standard</h4>
|
|
||||||
<p className="text-2xl font-bold text-blue-600">7-14 jours</p>
|
|
||||||
<p className="text-xs text-gray-500">Selon compagnie et port</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white p-4 rounded-lg border">
|
|
||||||
<h4 className="font-medium text-gray-900">Demurrage start</h4>
|
|
||||||
<p className="text-sm text-gray-600">
|
|
||||||
Commence après le free time au terminal
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white p-4 rounded-lg border">
|
|
||||||
<h4 className="font-medium text-gray-900">Detention start</h4>
|
|
||||||
<p className="text-sm text-gray-600">
|
|
||||||
Commence à la sortie du terminal (gate-out)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Late Fees */}
|
|
||||||
<div className="mt-8">
|
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-4">💸 Frais de Retard</h2>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
{fraisRetard.map((frais) => (
|
|
||||||
<Card key={frais.nom} className="bg-white border-red-200">
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle className="text-lg text-red-700">{frais.nom}</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-gray-600 text-sm mb-2">{frais.definition}</p>
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-gray-500">Taux indicatif :</span>
|
|
||||||
<span className="font-mono text-red-600">{frais.taux}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between text-sm mt-1">
|
|
||||||
<span className="text-gray-500">Lieu :</span>
|
|
||||||
<span className="text-gray-700">{frais.lieu}</span>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Factors affecting transit */}
|
|
||||||
<Card className="mt-8 bg-orange-50 border-orange-200">
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<h3 className="font-semibold text-orange-900 mb-3">⚡ Facteurs Impactant les Délais</h3>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<h4 className="font-medium text-orange-800">Retards potentiels</h4>
|
|
||||||
<ul className="text-sm text-orange-700 mt-2 space-y-1">
|
|
||||||
<li>• Congestion portuaire (Los Angeles, Rotterdam)</li>
|
|
||||||
<li>• Conditions météorologiques (typhons, tempêtes)</li>
|
|
||||||
<li>• Fermeture de canaux (Suez, Panama)</li>
|
|
||||||
<li>• Inspection douanière (scanner, contrôle)</li>
|
|
||||||
<li>• Blank sailings (annulation de rotation)</li>
|
|
||||||
<li>• Grèves (dockers, transporteurs)</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 className="font-medium text-orange-800">Variations saisonnières</h4>
|
|
||||||
<ul className="text-sm text-orange-700 mt-2 space-y-1">
|
|
||||||
<li>• Nouvel An Chinois (février) : +2-3 semaines</li>
|
|
||||||
<li>• Golden Week (octobre) : congestion Asie</li>
|
|
||||||
<li>• Peak Season (août-octobre) : surcharges, retards</li>
|
|
||||||
<li>• Fêtes de fin d'année : rush avant Christmas</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Roll-over */}
|
|
||||||
<Card className="mt-8 bg-gray-50">
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<h3 className="font-semibold text-gray-900 mb-3">🔄 Roll-over (Report)</h3>
|
|
||||||
<p className="text-gray-600 mb-3">
|
|
||||||
Situation où un conteneur n'est pas chargé sur le navire prévu et est reporté
|
|
||||||
sur le prochain départ.
|
|
||||||
</p>
|
|
||||||
<div className="bg-white p-4 rounded-lg border">
|
|
||||||
<h4 className="font-medium text-gray-900">Causes fréquentes :</h4>
|
|
||||||
<ul className="text-sm text-gray-600 mt-2 space-y-1">
|
|
||||||
<li>• Navire plein (overbooking)</li>
|
|
||||||
<li>• Conteneur arrivé après le cargo cut-off</li>
|
|
||||||
<li>• Documents manquants ou incorrects</li>
|
|
||||||
<li>• VGM non transmis à temps</li>
|
|
||||||
<li>• Problème avec la marchandise (DG, inspection)</li>
|
|
||||||
</ul>
|
|
||||||
<p className="text-xs text-gray-500 mt-3">
|
|
||||||
Impact : Généralement +7 jours de délai (service hebdomadaire)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Tips */}
|
|
||||||
<Card className="mt-8 bg-amber-50 border-amber-200">
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<h3 className="font-semibold text-amber-900 mb-3">💡 Conseils pour Optimiser les Délais</h3>
|
|
||||||
<ul className="list-disc list-inside space-y-2 text-amber-800">
|
|
||||||
<li>Réserver tôt, surtout en haute saison (2-3 semaines d'avance)</li>
|
|
||||||
<li>Respecter les cut-off avec une marge de sécurité (24h minimum)</li>
|
|
||||||
<li>Préparer les documents en parallèle de l'empotage</li>
|
|
||||||
<li>Négocier du free time supplémentaire pour les volumes importants</li>
|
|
||||||
<li>Tracker activement les navires (AIS, portails compagnies)</li>
|
|
||||||
<li>Anticiper le dédouanement (pré-clearing si possible)</li>
|
|
||||||
<li>Avoir un plan B en cas de roll-over (service alternatif)</li>
|
|
||||||
<li>Éviter les expéditions critiques pendant les périodes à risque</li>
|
|
||||||
</ul>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,268 +0,0 @@
|
|||||||
/**
|
|
||||||
* VGM (Verified Gross Mass) - Wiki Page
|
|
||||||
*
|
|
||||||
* Obligation de pesée des conteneurs (SOLAS)
|
|
||||||
*/
|
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
|
||||||
|
|
||||||
const methodesPesee = [
|
|
||||||
{
|
|
||||||
method: 'Méthode 1',
|
|
||||||
name: 'Pesée du conteneur complet',
|
|
||||||
description: 'Pesée du conteneur chargé et scellé sur une balance étalonnée.',
|
|
||||||
process: [
|
|
||||||
'Empotage du conteneur',
|
|
||||||
'Scellage du conteneur',
|
|
||||||
'Pesée sur pont-bascule certifié',
|
|
||||||
'Transmission du VGM',
|
|
||||||
],
|
|
||||||
advantages: ['Plus précis', 'Moins de calculs'],
|
|
||||||
disadvantages: ['Nécessite un pont-bascule', 'Conteneur déjà scellé'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
method: 'Méthode 2',
|
|
||||||
name: 'Calcul par addition',
|
|
||||||
description: 'Addition de la tare du conteneur et du poids de tous les éléments chargés.',
|
|
||||||
process: [
|
|
||||||
'Pesée de chaque colis individuellement',
|
|
||||||
'Addition de tous les poids',
|
|
||||||
'Ajout des matériaux d\'arrimage',
|
|
||||||
'Addition de la tare conteneur',
|
|
||||||
],
|
|
||||||
advantages: ['Pas besoin de pont-bascule', 'Peut être fait progressivement'],
|
|
||||||
disadvantages: ['Plus complexe', 'Risque d\'erreur cumulative'],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const sanctions = [
|
|
||||||
{ region: 'France', sanction: 'Amende jusqu\'à 7,500€ et refus d\'embarquement' },
|
|
||||||
{ region: 'USA', sanction: 'Refus d\'embarquement, amende par la garde côtière' },
|
|
||||||
{ region: 'Chine', sanction: 'Refus d\'embarquement, pénalités portuaires' },
|
|
||||||
{ region: 'Union Européenne', sanction: 'Application variable selon pays membre' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const elementsVGM = [
|
|
||||||
{ element: 'Tare conteneur', description: 'Poids à vide du conteneur (inscrit sur la porte)', example: '2,200 kg (20\')' },
|
|
||||||
{ element: 'Marchandises', description: 'Poids brut de toutes les marchandises', example: 'Variable' },
|
|
||||||
{ element: 'Emballages', description: 'Palettes, cartons, film plastique...', example: '200-500 kg' },
|
|
||||||
{ element: 'Matériaux d\'arrimage', description: 'Bois de calage, sangles, airbags...', example: '50-200 kg' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function VGMPage() {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Header with back link */}
|
|
||||||
<div className="flex items-center gap-4 mb-6">
|
|
||||||
<Link
|
|
||||||
href="/dashboard/wiki"
|
|
||||||
className="flex items-center text-blue-600 hover:text-blue-800 transition-colors"
|
|
||||||
>
|
|
||||||
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
|
||||||
</svg>
|
|
||||||
Retour au Wiki
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Title */}
|
|
||||||
<div className="mb-8">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="text-4xl">⚓</span>
|
|
||||||
<h1 className="text-3xl font-bold text-gray-900">VGM (Verified Gross Mass)</h1>
|
|
||||||
</div>
|
|
||||||
<p className="mt-3 text-gray-600 max-w-3xl">
|
|
||||||
Depuis le 1er juillet 2016, la Convention SOLAS (Safety of Life at Sea) exige que le poids
|
|
||||||
vérifié de tout conteneur soit transmis avant embarquement. Cette obligation vise à prévenir
|
|
||||||
les accidents liés aux conteneurs mal déclarés.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Why VGM */}
|
|
||||||
<Card className="bg-blue-50 border-blue-200">
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<h3 className="font-semibold text-blue-900 mb-3">Pourquoi le VGM ?</h3>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-blue-800">
|
|
||||||
<div>
|
|
||||||
<h4 className="font-medium">🛡️ Sécurité</h4>
|
|
||||||
<p className="text-sm">Les conteneurs mal déclarés causent des accidents graves (chute de conteneurs, navires instables).</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 className="font-medium">⚖️ Stabilité du navire</h4>
|
|
||||||
<p className="text-sm">Le capitaine doit connaître le poids exact pour calculer le plan de chargement.</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 className="font-medium">🏗️ Équipements portuaires</h4>
|
|
||||||
<p className="text-sm">Les grues et portiques sont dimensionnés pour des charges maximales.</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 className="font-medium">🚛 Transport terrestre</h4>
|
|
||||||
<p className="text-sm">Évite les surcharges sur les camions et wagons de pré/post-acheminement.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* VGM Components */}
|
|
||||||
<div className="mt-8">
|
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-4">📋 Composants du VGM</h2>
|
|
||||||
<Card className="bg-white">
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<div className="bg-gray-50 p-4 rounded-lg border mb-4">
|
|
||||||
<p className="text-center font-mono text-lg">
|
|
||||||
<span className="bg-blue-100 text-blue-800 px-2 py-1 rounded">VGM</span> = Tare + Marchandises + Emballages + Arrimage
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{elementsVGM.map((item) => (
|
|
||||||
<div key={item.element} className="flex items-center justify-between py-3 border-b last:border-0">
|
|
||||||
<div>
|
|
||||||
<h4 className="font-medium text-gray-900">{item.element}</h4>
|
|
||||||
<p className="text-sm text-gray-600">{item.description}</p>
|
|
||||||
</div>
|
|
||||||
<span className="text-sm font-mono bg-gray-100 px-3 py-1 rounded">{item.example}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Methods */}
|
|
||||||
<div className="mt-8">
|
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-4">🔬 Méthodes de Détermination</h2>
|
|
||||||
<div className="space-y-4">
|
|
||||||
{methodesPesee.map((method) => (
|
|
||||||
<Card key={method.method} className="bg-white">
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle className="flex items-center gap-3">
|
|
||||||
<span className="px-3 py-1 bg-green-600 text-white rounded-md">
|
|
||||||
{method.method}
|
|
||||||
</span>
|
|
||||||
<span className="text-lg">{method.name}</span>
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-gray-600 mb-4">{method.description}</p>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
||||||
<div>
|
|
||||||
<h4 className="font-medium text-gray-700 mb-2">Processus</h4>
|
|
||||||
<ol className="list-decimal list-inside text-sm text-gray-600 space-y-1">
|
|
||||||
{method.process.map((step, idx) => (
|
|
||||||
<li key={idx}>{step}</li>
|
|
||||||
))}
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 className="font-medium text-green-700 mb-2">✓ Avantages</h4>
|
|
||||||
<ul className="text-sm text-gray-600 space-y-1">
|
|
||||||
{method.advantages.map((adv) => (
|
|
||||||
<li key={adv} className="flex items-center gap-2">
|
|
||||||
<span className="w-1.5 h-1.5 bg-green-500 rounded-full"></span>
|
|
||||||
{adv}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 className="font-medium text-red-700 mb-2">✗ Inconvénients</h4>
|
|
||||||
<ul className="text-sm text-gray-600 space-y-1">
|
|
||||||
{method.disadvantages.map((dis) => (
|
|
||||||
<li key={dis} className="flex items-center gap-2">
|
|
||||||
<span className="w-1.5 h-1.5 bg-red-500 rounded-full"></span>
|
|
||||||
{dis}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Responsibility */}
|
|
||||||
<Card className="mt-8 bg-gray-50">
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<h3 className="font-semibold text-gray-900 mb-3">👤 Responsabilités</h3>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
||||||
<div className="bg-white p-4 rounded-lg border">
|
|
||||||
<h4 className="font-medium text-gray-900">Expéditeur (Shipper)</h4>
|
|
||||||
<p className="text-sm text-gray-600 mt-1">
|
|
||||||
Responsable légal du VGM. Doit obtenir, certifier et transmettre le poids vérifié.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white p-4 rounded-lg border">
|
|
||||||
<h4 className="font-medium text-gray-900">Transitaire</h4>
|
|
||||||
<p className="text-sm text-gray-600 mt-1">
|
|
||||||
Peut transmettre le VGM pour le compte de l'expéditeur. Reste un intermédiaire.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white p-4 rounded-lg border">
|
|
||||||
<h4 className="font-medium text-gray-900">Compagnie maritime</h4>
|
|
||||||
<p className="text-sm text-gray-600 mt-1">
|
|
||||||
Ne peut embarquer un conteneur sans VGM. Peut refuser un VGM manifestement erroné.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Tolerances */}
|
|
||||||
<Card className="mt-8 bg-gray-50">
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<h3 className="font-semibold text-gray-900 mb-3">📏 Tolérances</h3>
|
|
||||||
<div className="bg-white p-4 rounded-lg border">
|
|
||||||
<p className="text-gray-600 mb-3">
|
|
||||||
Les tolérances varient selon les compagnies maritimes et les ports, mais généralement :
|
|
||||||
</p>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
|
||||||
<div>
|
|
||||||
<span className="font-medium">Tolérance typique :</span>
|
|
||||||
<p className="text-gray-600">± 5% du poids déclaré ou ± 500 kg (le plus petit des deux)</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="font-medium">Conséquence si dépassement :</span>
|
|
||||||
<p className="text-gray-600">Nouvelle pesée à la charge de l'expéditeur, retard possible</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Sanctions */}
|
|
||||||
<div className="mt-8">
|
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-4">⚠️ Sanctions par Région</h2>
|
|
||||||
<Card className="bg-white">
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<div className="space-y-3">
|
|
||||||
{sanctions.map((s) => (
|
|
||||||
<div key={s.region} className="flex items-center justify-between py-3 border-b last:border-0">
|
|
||||||
<span className="font-medium text-gray-900">{s.region}</span>
|
|
||||||
<span className="text-sm text-red-600">{s.sanction}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tips */}
|
|
||||||
<Card className="mt-8 bg-amber-50 border-amber-200">
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<h3 className="font-semibold text-amber-900 mb-3">💡 Bonnes Pratiques</h3>
|
|
||||||
<ul className="list-disc list-inside space-y-2 text-amber-800">
|
|
||||||
<li>Transmettre le VGM au moins 24-48h avant le cut-off</li>
|
|
||||||
<li>Utiliser des balances étalonnées et certifiées</li>
|
|
||||||
<li>Conserver les preuves de pesée pendant 3 ans minimum</li>
|
|
||||||
<li>Vérifier les exigences spécifiques de chaque compagnie maritime</li>
|
|
||||||
<li>Former le personnel aux procédures VGM</li>
|
|
||||||
<li>Ne jamais sous-estimer le poids intentionnellement</li>
|
|
||||||
</ul>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,360 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licenses Tab Component
|
|
||||||
*
|
|
||||||
* Manages user licenses within the organization
|
|
||||||
*/
|
|
||||||
|
|
||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { useQuery } from '@tanstack/react-query';
|
|
||||||
import { getSubscriptionOverview } from '@/lib/api/subscriptions';
|
|
||||||
|
|
||||||
export default function LicensesTab() {
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
const [success, setSuccess] = useState('');
|
|
||||||
|
|
||||||
const { data: subscription, isLoading } = useQuery({
|
|
||||||
queryKey: ['subscription'],
|
|
||||||
queryFn: getSubscriptionOverview,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="animate-pulse space-y-4">
|
|
||||||
<div className="h-8 bg-gray-200 rounded w-1/4"></div>
|
|
||||||
<div className="h-64 bg-gray-200 rounded"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const licenses = subscription?.licenses || [];
|
|
||||||
const activeLicenses = licenses.filter((l) => l.status === 'ACTIVE');
|
|
||||||
const revokedLicenses = licenses.filter((l) => l.status === 'REVOKED');
|
|
||||||
|
|
||||||
const usagePercentage = subscription
|
|
||||||
? subscription.maxLicenses === -1
|
|
||||||
? 0
|
|
||||||
: (subscription.usedLicenses / subscription.maxLicenses) * 100
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Alerts */}
|
|
||||||
{error && (
|
|
||||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{success && (
|
|
||||||
<div className="bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded-lg">
|
|
||||||
{success}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* License Summary */}
|
|
||||||
<div className="bg-gray-50 rounded-lg p-6">
|
|
||||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Résumé des licences</h3>
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
|
||||||
<div className="bg-white rounded-lg p-4 border border-gray-200">
|
|
||||||
<p className="text-sm text-gray-500">Licences utilisées</p>
|
|
||||||
<p className="text-2xl font-bold text-gray-900">
|
|
||||||
{subscription?.usedLicenses || 0}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-gray-400 mt-1">Hors ADMIN (illimité)</p>
|
|
||||||
</div>
|
|
||||||
<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-2xl font-bold text-gray-900">
|
|
||||||
{subscription?.availableLicenses === -1
|
|
||||||
? 'Illimité'
|
|
||||||
: subscription?.availableLicenses || 0}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white rounded-lg p-4 border border-gray-200">
|
|
||||||
<p className="text-sm text-gray-500">Licences totales</p>
|
|
||||||
<p className="text-2xl font-bold text-gray-900">
|
|
||||||
{subscription?.maxLicenses === -1
|
|
||||||
? 'Illimité'
|
|
||||||
: subscription?.maxLicenses || 0}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Usage Bar */}
|
|
||||||
{subscription && subscription.maxLicenses !== -1 && (
|
|
||||||
<div className="mt-4">
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<span className="text-sm font-medium text-gray-700">Utilisation</span>
|
|
||||||
<span className="text-sm text-gray-500">
|
|
||||||
{Math.round(usagePercentage)}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-full bg-gray-200 rounded-full h-2.5">
|
|
||||||
<div
|
|
||||||
className={`h-2.5 rounded-full ${
|
|
||||||
usagePercentage >= 90
|
|
||||||
? 'bg-red-600'
|
|
||||||
: usagePercentage >= 70
|
|
||||||
? 'bg-yellow-500'
|
|
||||||
: 'bg-blue-600'
|
|
||||||
}`}
|
|
||||||
style={{ width: `${Math.min(usagePercentage, 100)}%` }}
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Active Licenses */}
|
|
||||||
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
|
||||||
<div className="px-6 py-4 border-b border-gray-200 bg-gray-50">
|
|
||||||
<h3 className="text-lg font-medium text-gray-900">
|
|
||||||
Licences actives ({activeLicenses.length})
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
{activeLicenses.length === 0 ? (
|
|
||||||
<div className="px-6 py-8 text-center text-gray-500">
|
|
||||||
Aucune licence active
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="min-w-full divide-y divide-gray-200">
|
|
||||||
<thead className="bg-gray-50">
|
|
||||||
<tr>
|
|
||||||
<th
|
|
||||||
scope="col"
|
|
||||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
|
||||||
>
|
|
||||||
Utilisateur
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
scope="col"
|
|
||||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
|
||||||
>
|
|
||||||
Email
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
scope="col"
|
|
||||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
|
||||||
>
|
|
||||||
Rôle
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
scope="col"
|
|
||||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
|
||||||
>
|
|
||||||
Assignée le
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
scope="col"
|
|
||||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
|
||||||
>
|
|
||||||
Licence
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
|
||||||
{activeLicenses.map((license) => {
|
|
||||||
const isAdmin = license.userRole === 'ADMIN';
|
|
||||||
return (
|
|
||||||
<tr key={license.id}>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
|
||||||
<div className="text-sm font-medium text-gray-900">
|
|
||||||
{license.userName}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
|
||||||
<div className="text-sm text-gray-500">{license.userEmail}</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
|
||||||
<span className={`px-2 py-1 text-xs font-medium rounded-full ${
|
|
||||||
isAdmin
|
|
||||||
? 'bg-purple-100 text-purple-800'
|
|
||||||
: 'bg-gray-100 text-gray-600'
|
|
||||||
}`}>
|
|
||||||
{license.userRole}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
|
||||||
<div className="text-sm text-gray-500">
|
|
||||||
{new Date(license.assignedAt).toLocaleDateString('fr-FR', {
|
|
||||||
day: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
year: 'numeric',
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
|
||||||
{isAdmin ? (
|
|
||||||
<span className="px-2 py-1 text-xs font-medium rounded-full bg-amber-100 text-amber-800">
|
|
||||||
Illimité
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="px-2 py-1 text-xs font-medium rounded-full bg-green-100 text-green-800">
|
|
||||||
Active
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Revoked Licenses (History) */}
|
|
||||||
{revokedLicenses.length > 0 && (
|
|
||||||
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
|
||||||
<div className="px-6 py-4 border-b border-gray-200 bg-gray-50">
|
|
||||||
<h3 className="text-lg font-medium text-gray-900">
|
|
||||||
Historique des licences révoquées ({revokedLicenses.length})
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="min-w-full divide-y divide-gray-200">
|
|
||||||
<thead className="bg-gray-50">
|
|
||||||
<tr>
|
|
||||||
<th
|
|
||||||
scope="col"
|
|
||||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
|
||||||
>
|
|
||||||
Utilisateur
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
scope="col"
|
|
||||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
|
||||||
>
|
|
||||||
Email
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
scope="col"
|
|
||||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
|
||||||
>
|
|
||||||
Rôle
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
scope="col"
|
|
||||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
|
||||||
>
|
|
||||||
Assignée le
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
scope="col"
|
|
||||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
|
||||||
>
|
|
||||||
Révoquée le
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
scope="col"
|
|
||||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
|
||||||
>
|
|
||||||
Statut
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
|
||||||
{revokedLicenses.map((license) => (
|
|
||||||
<tr key={license.id}>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
|
||||||
<div className="text-sm font-medium text-gray-900">
|
|
||||||
{license.userName}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
|
||||||
<div className="text-sm text-gray-500">{license.userEmail}</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
|
||||||
<span className={`px-2 py-1 text-xs font-medium rounded-full ${
|
|
||||||
license.userRole === 'ADMIN'
|
|
||||||
? 'bg-purple-100 text-purple-800'
|
|
||||||
: 'bg-gray-100 text-gray-600'
|
|
||||||
}`}>
|
|
||||||
{license.userRole}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
|
||||||
<div className="text-sm text-gray-500">
|
|
||||||
{new Date(license.assignedAt).toLocaleDateString('fr-FR', {
|
|
||||||
day: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
year: 'numeric',
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
|
||||||
<div className="text-sm text-gray-500">
|
|
||||||
{license.revokedAt
|
|
||||||
? new Date(license.revokedAt).toLocaleDateString('fr-FR', {
|
|
||||||
day: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
year: 'numeric',
|
|
||||||
})
|
|
||||||
: '-'}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
|
||||||
<span className="px-2 py-1 text-xs font-medium rounded-full bg-gray-100 text-gray-600">
|
|
||||||
Révoquée
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Info Box */}
|
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
|
||||||
<div className="flex">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<svg
|
|
||||||
className="h-5 w-5 text-blue-400"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
fill="currentColor"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fillRule="evenodd"
|
|
||||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
|
|
||||||
clipRule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div className="ml-3">
|
|
||||||
<h3 className="text-sm font-medium text-blue-800">
|
|
||||||
Comment fonctionnent les licences ?
|
|
||||||
</h3>
|
|
||||||
<div className="mt-2 text-sm text-blue-700">
|
|
||||||
<ul className="list-disc list-inside space-y-1">
|
|
||||||
<li>
|
|
||||||
Chaque utilisateur actif de votre organisation consomme une licence
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>Les administrateurs (ADMIN) ont des licences illimitées</strong> et ne sont pas comptés dans le quota
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Les licences sont automatiquement assignées lors de l'ajout d'un
|
|
||||||
utilisateur
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Les licences sont libérées lorsqu'un utilisateur est désactivé ou
|
|
||||||
supprimé
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Pour ajouter plus d'utilisateurs, passez à un plan supérieur dans
|
|
||||||
l'onglet Abonnement
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,443 +0,0 @@
|
|||||||
/**
|
|
||||||
* Subscription Tab Component
|
|
||||||
*
|
|
||||||
* Manages subscription plan, billing, and upgrade flows
|
|
||||||
*/
|
|
||||||
|
|
||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
|
||||||
import { useSearchParams, useRouter } from 'next/navigation';
|
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
||||||
import {
|
|
||||||
getSubscriptionOverview,
|
|
||||||
getAllPlans,
|
|
||||||
createCheckoutSession,
|
|
||||||
createPortalSession,
|
|
||||||
syncSubscriptionFromStripe,
|
|
||||||
formatPrice,
|
|
||||||
getPlanBadgeColor,
|
|
||||||
getStatusBadgeColor,
|
|
||||||
type SubscriptionPlan,
|
|
||||||
type BillingInterval,
|
|
||||||
} from '@/lib/api/subscriptions';
|
|
||||||
|
|
||||||
export default function SubscriptionTab() {
|
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const router = useRouter();
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const [billingInterval, setBillingInterval] = useState<BillingInterval>('monthly');
|
|
||||||
const [selectedPlan, setSelectedPlan] = useState<SubscriptionPlan | null>(null);
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
const [success, setSuccess] = useState('');
|
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
||||||
|
|
||||||
const { data: subscription, isLoading: loadingSubscription, refetch: refetchSubscription } = useQuery({
|
|
||||||
queryKey: ['subscription'],
|
|
||||||
queryFn: getSubscriptionOverview,
|
|
||||||
// Refetch more frequently when we're waiting for webhook
|
|
||||||
refetchInterval: isRefreshing ? 2000 : false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: plansData, isLoading: loadingPlans } = useQuery({
|
|
||||||
queryKey: ['plans'],
|
|
||||||
queryFn: getAllPlans,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle success/cancel from Stripe redirect
|
|
||||||
const handleStripeRedirect = useCallback(async () => {
|
|
||||||
const isSuccess = searchParams.get('success') === 'true';
|
|
||||||
const isCanceled = searchParams.get('canceled') === 'true';
|
|
||||||
const sessionId = searchParams.get('session_id') || undefined;
|
|
||||||
|
|
||||||
if (isSuccess) {
|
|
||||||
setSuccess('Votre abonnement a été mis à jour avec succès !');
|
|
||||||
setIsRefreshing(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Sync from Stripe using the session ID (works even without webhooks)
|
|
||||||
// The session ID allows us to retrieve the subscription from the checkout session
|
|
||||||
console.log('Syncing subscription with sessionId:', sessionId);
|
|
||||||
await syncSubscriptionFromStripe(sessionId);
|
|
||||||
|
|
||||||
// Then invalidate and refetch to get fresh data
|
|
||||||
await queryClient.invalidateQueries({ queryKey: ['subscription'] });
|
|
||||||
await queryClient.invalidateQueries({ queryKey: ['canInvite'] });
|
|
||||||
await refetchSubscription();
|
|
||||||
|
|
||||||
// Wait a bit and refetch again to ensure data is up to date
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
|
||||||
await syncSubscriptionFromStripe(sessionId);
|
|
||||||
await queryClient.invalidateQueries({ queryKey: ['subscription'] });
|
|
||||||
await refetchSubscription();
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error syncing subscription:', err);
|
|
||||||
// Fallback: just refetch
|
|
||||||
await refetchSubscription();
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsRefreshing(false);
|
|
||||||
|
|
||||||
// Clear the URL params after processing
|
|
||||||
router.replace('/dashboard/settings/organization', { scroll: false });
|
|
||||||
|
|
||||||
setTimeout(() => setSuccess(''), 5000);
|
|
||||||
} else if (isCanceled) {
|
|
||||||
setError('Le paiement a été annulé. Votre abonnement n\'a pas été modifié.');
|
|
||||||
router.replace('/dashboard/settings/organization', { scroll: false });
|
|
||||||
setTimeout(() => setError(''), 5000);
|
|
||||||
}
|
|
||||||
}, [searchParams, queryClient, refetchSubscription, router]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
handleStripeRedirect();
|
|
||||||
}, [handleStripeRedirect]);
|
|
||||||
|
|
||||||
const checkoutMutation = useMutation({
|
|
||||||
mutationFn: (plan: SubscriptionPlan) =>
|
|
||||||
createCheckoutSession({ plan, billingInterval }),
|
|
||||||
onSuccess: (data) => {
|
|
||||||
window.location.href = data.sessionUrl;
|
|
||||||
},
|
|
||||||
onError: (err: Error) => {
|
|
||||||
setError(err.message || 'Erreur lors de la création de la session de paiement');
|
|
||||||
setTimeout(() => setError(''), 5000);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const portalMutation = useMutation({
|
|
||||||
mutationFn: () => createPortalSession(),
|
|
||||||
onSuccess: (data) => {
|
|
||||||
window.location.href = data.sessionUrl;
|
|
||||||
},
|
|
||||||
onError: (err: Error) => {
|
|
||||||
setError(err.message || 'Erreur lors de l\'ouverture du portail de facturation');
|
|
||||||
setTimeout(() => setError(''), 5000);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleUpgrade = (plan: SubscriptionPlan) => {
|
|
||||||
if (plan === 'FREE') return;
|
|
||||||
setSelectedPlan(plan);
|
|
||||||
checkoutMutation.mutate(plan);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleManageBilling = () => {
|
|
||||||
portalMutation.mutate();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRefresh = async () => {
|
|
||||||
setIsRefreshing(true);
|
|
||||||
try {
|
|
||||||
// Sync from Stripe first
|
|
||||||
await syncSubscriptionFromStripe();
|
|
||||||
// Then invalidate and refetch
|
|
||||||
await queryClient.invalidateQueries({ queryKey: ['subscription'] });
|
|
||||||
await queryClient.invalidateQueries({ queryKey: ['canInvite'] });
|
|
||||||
await refetchSubscription();
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error syncing subscription:', err);
|
|
||||||
// Fallback: just refetch
|
|
||||||
await refetchSubscription();
|
|
||||||
}
|
|
||||||
setIsRefreshing(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const isCurrentPlan = (plan: SubscriptionPlan): boolean => {
|
|
||||||
return subscription?.plan === plan;
|
|
||||||
};
|
|
||||||
|
|
||||||
const canUpgrade = (plan: SubscriptionPlan): boolean => {
|
|
||||||
if (!subscription) return false;
|
|
||||||
const planOrder: SubscriptionPlan[] = ['FREE', 'STARTER', 'PRO', 'ENTERPRISE'];
|
|
||||||
return planOrder.indexOf(plan) > planOrder.indexOf(subscription.plan);
|
|
||||||
};
|
|
||||||
|
|
||||||
const isLoading = loadingSubscription || loadingPlans;
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="animate-pulse space-y-4">
|
|
||||||
<div className="h-8 bg-gray-200 rounded w-1/4"></div>
|
|
||||||
<div className="h-32 bg-gray-200 rounded"></div>
|
|
||||||
<div className="h-64 bg-gray-200 rounded"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const usagePercentage = subscription
|
|
||||||
? subscription.maxLicenses === -1
|
|
||||||
? 0
|
|
||||||
: (subscription.usedLicenses / subscription.maxLicenses) * 100
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Alerts */}
|
|
||||||
{error && (
|
|
||||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{success && (
|
|
||||||
<div className="bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded-lg flex items-center justify-between">
|
|
||||||
<span>{success}</span>
|
|
||||||
{isRefreshing && (
|
|
||||||
<span className="text-sm text-green-600 flex items-center gap-2">
|
|
||||||
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
|
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
|
||||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
|
||||||
</svg>
|
|
||||||
Mise à jour...
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Current Plan */}
|
|
||||||
{subscription && (
|
|
||||||
<div className="bg-gray-50 rounded-lg p-6">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<h3 className="text-lg font-medium text-gray-900">Plan actuel</h3>
|
|
||||||
<button
|
|
||||||
onClick={handleRefresh}
|
|
||||||
disabled={isRefreshing}
|
|
||||||
className="text-sm text-blue-600 hover:text-blue-800 disabled:opacity-50 flex items-center gap-1"
|
|
||||||
>
|
|
||||||
<svg className={`h-4 w-4 ${isRefreshing ? 'animate-spin' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
|
||||||
</svg>
|
|
||||||
{isRefreshing ? 'Actualisation...' : 'Actualiser'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
|
||||||
<span
|
|
||||||
className={`px-3 py-1 rounded-full text-sm font-medium ${getPlanBadgeColor(subscription.plan)}`}
|
|
||||||
>
|
|
||||||
{subscription.planDetails.name}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className={`px-3 py-1 rounded-full text-sm font-medium ${getStatusBadgeColor(subscription.status)}`}
|
|
||||||
>
|
|
||||||
{subscription.status === 'ACTIVE' ? 'Actif' : subscription.status}
|
|
||||||
</span>
|
|
||||||
{subscription.cancelAtPeriodEnd && (
|
|
||||||
<span className="px-3 py-1 rounded-full text-sm font-medium bg-orange-100 text-orange-800">
|
|
||||||
Annulation prévue
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{subscription.plan !== 'FREE' && (
|
|
||||||
<button
|
|
||||||
onClick={handleManageBilling}
|
|
||||||
disabled={portalMutation.isPending}
|
|
||||||
className="px-4 py-2 text-sm font-medium text-blue-600 hover:text-blue-800 hover:bg-blue-50 rounded-lg disabled:opacity-50 transition-colors"
|
|
||||||
>
|
|
||||||
{portalMutation.isPending ? 'Chargement...' : 'Gérer la facturation'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* License Usage */}
|
|
||||||
<div className="mt-6">
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<span className="text-sm font-medium text-gray-700">Utilisation des licences</span>
|
|
||||||
<span className="text-sm text-gray-500">
|
|
||||||
{subscription.usedLicenses} /{' '}
|
|
||||||
{subscription.maxLicenses === -1 ? 'Illimité' : subscription.maxLicenses}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-full bg-gray-200 rounded-full h-2.5">
|
|
||||||
<div
|
|
||||||
className={`h-2.5 rounded-full ${
|
|
||||||
usagePercentage >= 90
|
|
||||||
? 'bg-red-600'
|
|
||||||
: usagePercentage >= 70
|
|
||||||
? 'bg-yellow-500'
|
|
||||||
: 'bg-blue-600'
|
|
||||||
}`}
|
|
||||||
style={{
|
|
||||||
width:
|
|
||||||
subscription.maxLicenses === -1
|
|
||||||
? '10%'
|
|
||||||
: `${Math.min(usagePercentage, 100)}%`,
|
|
||||||
}}
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
{subscription.availableLicenses !== -1 && subscription.availableLicenses <= 2 && (
|
|
||||||
<p className="mt-2 text-sm text-amber-600">
|
|
||||||
{subscription.availableLicenses === 0
|
|
||||||
? 'Aucune licence disponible. Passez à un plan supérieur pour ajouter des utilisateurs.'
|
|
||||||
: `Plus que ${subscription.availableLicenses} licence${subscription.availableLicenses === 1 ? '' : 's'} disponible${subscription.availableLicenses === 1 ? '' : 's'}.`}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<p className="mt-2 text-xs text-gray-400">
|
|
||||||
Les administrateurs (ADMIN) ont des licences illimitées et ne sont pas comptés.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Billing Period */}
|
|
||||||
{subscription.currentPeriodEnd && (
|
|
||||||
<div className="mt-4 text-sm text-gray-500">
|
|
||||||
Période actuelle : jusqu'au{' '}
|
|
||||||
{new Date(subscription.currentPeriodEnd).toLocaleDateString('fr-FR', {
|
|
||||||
day: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
year: 'numeric',
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Plans Grid */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
|
||||||
<h3 className="text-lg font-medium text-gray-900">Plans disponibles</h3>
|
|
||||||
<div className="flex items-center gap-2 bg-gray-100 p-1 rounded-lg">
|
|
||||||
<button
|
|
||||||
onClick={() => setBillingInterval('monthly')}
|
|
||||||
className={`px-4 py-2 text-sm font-medium rounded-md transition ${
|
|
||||||
billingInterval === 'monthly'
|
|
||||||
? 'bg-white shadow text-gray-900'
|
|
||||||
: 'text-gray-500 hover:text-gray-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Mensuel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setBillingInterval('yearly')}
|
|
||||||
className={`px-4 py-2 text-sm font-medium rounded-md transition ${
|
|
||||||
billingInterval === 'yearly'
|
|
||||||
? 'bg-white shadow text-gray-900'
|
|
||||||
: 'text-gray-500 hover:text-gray-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Annuel
|
|
||||||
<span className="ml-1 text-xs text-green-600">-20%</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
||||||
{plansData?.plans.map((plan) => (
|
|
||||||
<div
|
|
||||||
key={plan.plan}
|
|
||||||
className={`bg-white rounded-lg border-2 p-5 ${
|
|
||||||
isCurrentPlan(plan.plan)
|
|
||||||
? 'border-blue-500 shadow-md'
|
|
||||||
: 'border-gray-200 hover:border-gray-300 hover:shadow transition'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="text-center">
|
|
||||||
<h4 className="text-lg font-semibold text-gray-900">{plan.name}</h4>
|
|
||||||
<div className="mt-3">
|
|
||||||
<span className="text-2xl font-bold text-gray-900">
|
|
||||||
{plan.plan === 'ENTERPRISE'
|
|
||||||
? 'Sur devis'
|
|
||||||
: formatPrice(
|
|
||||||
billingInterval === 'yearly'
|
|
||||||
? plan.yearlyPriceEur
|
|
||||||
: plan.monthlyPriceEur,
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
{plan.plan !== 'ENTERPRISE' && plan.plan !== 'FREE' && (
|
|
||||||
<span className="text-gray-500 text-sm">
|
|
||||||
/{billingInterval === 'yearly' ? 'an' : 'mois'}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="mt-2 text-sm text-gray-500">
|
|
||||||
{plan.maxLicenses === -1
|
|
||||||
? 'Utilisateurs illimités'
|
|
||||||
: `Jusqu'à ${plan.maxLicenses} utilisateurs`}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ul className="mt-4 space-y-2">
|
|
||||||
{plan.features.slice(0, 4).map((feature, index) => (
|
|
||||||
<li key={index} className="flex items-start gap-2">
|
|
||||||
<svg
|
|
||||||
className="h-5 w-5 text-green-500 flex-shrink-0 mt-0.5"
|
|
||||||
fill="currentColor"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fillRule="evenodd"
|
|
||||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
|
||||||
clipRule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span className="text-sm text-gray-600">{feature}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<div className="mt-5">
|
|
||||||
{isCurrentPlan(plan.plan) ? (
|
|
||||||
<button
|
|
||||||
disabled
|
|
||||||
className="w-full px-4 py-2 text-sm font-medium text-gray-500 bg-gray-100 rounded-lg cursor-not-allowed"
|
|
||||||
>
|
|
||||||
Plan actuel
|
|
||||||
</button>
|
|
||||||
) : plan.plan === 'ENTERPRISE' ? (
|
|
||||||
<a
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
Nous contacter
|
|
||||||
</a>
|
|
||||||
) : canUpgrade(plan.plan) ? (
|
|
||||||
<button
|
|
||||||
onClick={() => handleUpgrade(plan.plan)}
|
|
||||||
disabled={
|
|
||||||
checkoutMutation.isPending && selectedPlan === plan.plan
|
|
||||||
}
|
|
||||||
className="w-full px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{checkoutMutation.isPending && selectedPlan === plan.plan
|
|
||||||
? 'Chargement...'
|
|
||||||
: 'Passer à ce plan'}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
disabled
|
|
||||||
className="w-full px-4 py-2 text-sm font-medium text-gray-400 bg-gray-100 rounded-lg cursor-not-allowed"
|
|
||||||
>
|
|
||||||
Rétrograder via Facturation
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Info about webhooks in development */}
|
|
||||||
{process.env.NODE_ENV === 'development' && (
|
|
||||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
|
||||||
<div className="flex">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<svg className="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor">
|
|
||||||
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div className="ml-3">
|
|
||||||
<h3 className="text-sm font-medium text-yellow-800">Mode développement</h3>
|
|
||||||
<div className="mt-2 text-sm text-yellow-700">
|
|
||||||
<p>
|
|
||||||
Pour que les webhooks Stripe fonctionnent en local, exécutez :{' '}
|
|
||||||
<code className="bg-yellow-100 px-1 rounded">stripe listen --forward-to localhost:4000/api/v1/subscriptions/webhook</code>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -136,26 +136,6 @@ export {
|
|||||||
type DashboardAlert,
|
type DashboardAlert,
|
||||||
} from './dashboard';
|
} from './dashboard';
|
||||||
|
|
||||||
// Subscriptions (5 endpoints)
|
|
||||||
export {
|
|
||||||
getSubscriptionOverview,
|
|
||||||
getAllPlans,
|
|
||||||
canInviteUser,
|
|
||||||
createCheckoutSession,
|
|
||||||
createPortalSession,
|
|
||||||
formatPrice,
|
|
||||||
getPlanBadgeColor,
|
|
||||||
getStatusBadgeColor,
|
|
||||||
type SubscriptionPlan,
|
|
||||||
type SubscriptionStatus,
|
|
||||||
type BillingInterval,
|
|
||||||
type PlanDetails,
|
|
||||||
type LicenseResponse,
|
|
||||||
type SubscriptionOverviewResponse,
|
|
||||||
type CanInviteResponse,
|
|
||||||
type AllPlansResponse,
|
|
||||||
} from './subscriptions';
|
|
||||||
|
|
||||||
// Re-export as API objects for backward compatibility
|
// Re-export as API objects for backward compatibility
|
||||||
import * as bookingsModule from './bookings';
|
import * as bookingsModule from './bookings';
|
||||||
import * as ratesModule from './rates';
|
import * as ratesModule from './rates';
|
||||||
@ -166,7 +146,6 @@ import * as notificationsModule from './notifications';
|
|||||||
import * as auditModule from './audit';
|
import * as auditModule from './audit';
|
||||||
import * as webhooksModule from './webhooks';
|
import * as webhooksModule from './webhooks';
|
||||||
import * as gdprModule from './gdpr';
|
import * as gdprModule from './gdpr';
|
||||||
import * as subscriptionsModule from './subscriptions';
|
|
||||||
|
|
||||||
export const bookingsApi = bookingsModule;
|
export const bookingsApi = bookingsModule;
|
||||||
export const ratesApi = ratesModule;
|
export const ratesApi = ratesModule;
|
||||||
@ -177,4 +156,3 @@ export const notificationsApi = notificationsModule;
|
|||||||
export const auditApi = auditModule;
|
export const auditApi = auditModule;
|
||||||
export const webhooksApi = webhooksModule;
|
export const webhooksApi = webhooksModule;
|
||||||
export const gdprApi = gdprModule;
|
export const gdprApi = gdprModule;
|
||||||
export const subscriptionsApi = subscriptionsModule;
|
|
||||||
|
|||||||
@ -1,226 +0,0 @@
|
|||||||
/**
|
|
||||||
* Subscriptions API Client
|
|
||||||
*
|
|
||||||
* API functions for subscription and license management
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { get, post } from './client';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Subscription plan types
|
|
||||||
*/
|
|
||||||
export type SubscriptionPlan = 'FREE' | 'STARTER' | 'PRO' | 'ENTERPRISE';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Subscription status types
|
|
||||||
*/
|
|
||||||
export type SubscriptionStatus =
|
|
||||||
| 'ACTIVE'
|
|
||||||
| 'PAST_DUE'
|
|
||||||
| 'CANCELED'
|
|
||||||
| 'INCOMPLETE'
|
|
||||||
| 'INCOMPLETE_EXPIRED'
|
|
||||||
| 'TRIALING'
|
|
||||||
| 'UNPAID'
|
|
||||||
| 'PAUSED';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Billing interval types
|
|
||||||
*/
|
|
||||||
export type BillingInterval = 'monthly' | 'yearly';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Plan details
|
|
||||||
*/
|
|
||||||
export interface PlanDetails {
|
|
||||||
plan: SubscriptionPlan;
|
|
||||||
name: string;
|
|
||||||
maxLicenses: number;
|
|
||||||
monthlyPriceEur: number;
|
|
||||||
yearlyPriceEur: number;
|
|
||||||
features: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* License response
|
|
||||||
*/
|
|
||||||
export interface LicenseResponse {
|
|
||||||
id: string;
|
|
||||||
userId: string;
|
|
||||||
userEmail: string;
|
|
||||||
userName: string;
|
|
||||||
userRole: string;
|
|
||||||
status: 'ACTIVE' | 'REVOKED';
|
|
||||||
assignedAt: string;
|
|
||||||
revokedAt?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Subscription overview response
|
|
||||||
*/
|
|
||||||
export interface SubscriptionOverviewResponse {
|
|
||||||
id: string;
|
|
||||||
organizationId: string;
|
|
||||||
plan: SubscriptionPlan;
|
|
||||||
planDetails: PlanDetails;
|
|
||||||
status: SubscriptionStatus;
|
|
||||||
usedLicenses: number;
|
|
||||||
maxLicenses: number;
|
|
||||||
availableLicenses: number;
|
|
||||||
cancelAtPeriodEnd: boolean;
|
|
||||||
currentPeriodStart?: string;
|
|
||||||
currentPeriodEnd?: string;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
licenses: LicenseResponse[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Can invite response
|
|
||||||
*/
|
|
||||||
export interface CanInviteResponse {
|
|
||||||
canInvite: boolean;
|
|
||||||
availableLicenses: number;
|
|
||||||
usedLicenses: number;
|
|
||||||
maxLicenses: number;
|
|
||||||
message?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* All plans response
|
|
||||||
*/
|
|
||||||
export interface AllPlansResponse {
|
|
||||||
plans: PlanDetails[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checkout session request
|
|
||||||
*/
|
|
||||||
export interface CreateCheckoutSessionRequest {
|
|
||||||
plan: SubscriptionPlan;
|
|
||||||
billingInterval: BillingInterval;
|
|
||||||
successUrl?: string;
|
|
||||||
cancelUrl?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checkout session response
|
|
||||||
*/
|
|
||||||
export interface CheckoutSessionResponse {
|
|
||||||
sessionId: string;
|
|
||||||
sessionUrl: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Portal session request
|
|
||||||
*/
|
|
||||||
export interface CreatePortalSessionRequest {
|
|
||||||
returnUrl?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Portal session response
|
|
||||||
*/
|
|
||||||
export interface PortalSessionResponse {
|
|
||||||
sessionUrl: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get subscription overview for current organization
|
|
||||||
*/
|
|
||||||
export async function getSubscriptionOverview(): Promise<SubscriptionOverviewResponse> {
|
|
||||||
return get<SubscriptionOverviewResponse>('/api/v1/subscriptions');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all available plans
|
|
||||||
*/
|
|
||||||
export async function getAllPlans(): Promise<AllPlansResponse> {
|
|
||||||
return get<AllPlansResponse>('/api/v1/subscriptions/plans');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if organization can invite more users
|
|
||||||
*/
|
|
||||||
export async function canInviteUser(): Promise<CanInviteResponse> {
|
|
||||||
return get<CanInviteResponse>('/api/v1/subscriptions/can-invite');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a Stripe Checkout session for subscription upgrade
|
|
||||||
*/
|
|
||||||
export async function createCheckoutSession(
|
|
||||||
data: CreateCheckoutSessionRequest,
|
|
||||||
): Promise<CheckoutSessionResponse> {
|
|
||||||
return post<CheckoutSessionResponse>('/api/v1/subscriptions/checkout', data);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a Stripe Customer Portal session
|
|
||||||
*/
|
|
||||||
export async function createPortalSession(
|
|
||||||
data?: CreatePortalSessionRequest,
|
|
||||||
): Promise<PortalSessionResponse> {
|
|
||||||
return post<PortalSessionResponse>('/api/v1/subscriptions/portal', data || {});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sync subscription from Stripe
|
|
||||||
* Useful when webhooks are not available (e.g., local development)
|
|
||||||
* @param sessionId - Optional Stripe checkout session ID (pass after checkout completes)
|
|
||||||
*/
|
|
||||||
export async function syncSubscriptionFromStripe(sessionId?: string): Promise<SubscriptionOverviewResponse> {
|
|
||||||
return post<SubscriptionOverviewResponse>('/api/v1/subscriptions/sync', { sessionId });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format price for display
|
|
||||||
*/
|
|
||||||
export function formatPrice(amount: number, currency = 'EUR'): string {
|
|
||||||
return new Intl.NumberFormat('fr-FR', {
|
|
||||||
style: 'currency',
|
|
||||||
currency,
|
|
||||||
minimumFractionDigits: 0,
|
|
||||||
maximumFractionDigits: 0,
|
|
||||||
}).format(amount);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get plan badge color class
|
|
||||||
*/
|
|
||||||
export function getPlanBadgeColor(plan: SubscriptionPlan): string {
|
|
||||||
switch (plan) {
|
|
||||||
case 'FREE':
|
|
||||||
return 'bg-gray-100 text-gray-800';
|
|
||||||
case 'STARTER':
|
|
||||||
return 'bg-blue-100 text-blue-800';
|
|
||||||
case 'PRO':
|
|
||||||
return 'bg-purple-100 text-purple-800';
|
|
||||||
case 'ENTERPRISE':
|
|
||||||
return 'bg-amber-100 text-amber-800';
|
|
||||||
default:
|
|
||||||
return 'bg-gray-100 text-gray-800';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get status badge color class
|
|
||||||
*/
|
|
||||||
export function getStatusBadgeColor(status: SubscriptionStatus): string {
|
|
||||||
switch (status) {
|
|
||||||
case 'ACTIVE':
|
|
||||||
case 'TRIALING':
|
|
||||||
return 'bg-green-100 text-green-800';
|
|
||||||
case 'PAST_DUE':
|
|
||||||
return 'bg-yellow-100 text-yellow-800';
|
|
||||||
case 'CANCELED':
|
|
||||||
case 'INCOMPLETE_EXPIRED':
|
|
||||||
case 'UNPAID':
|
|
||||||
return 'bg-red-100 text-red-800';
|
|
||||||
case 'INCOMPLETE':
|
|
||||||
case 'PAUSED':
|
|
||||||
return 'bg-gray-100 text-gray-800';
|
|
||||||
default:
|
|
||||||
return 'bg-gray-100 text-gray-800';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,219 +0,0 @@
|
|||||||
# Configuration Stripe pour Xpeditis
|
|
||||||
|
|
||||||
Ce guide explique comment configurer Stripe pour le système de licences et d'abonnements.
|
|
||||||
|
|
||||||
## 1. Prérequis
|
|
||||||
|
|
||||||
- Compte Stripe (https://dashboard.stripe.com)
|
|
||||||
- Accès aux clés API Stripe
|
|
||||||
|
|
||||||
## 2. Configuration du Dashboard Stripe
|
|
||||||
|
|
||||||
### 2.1 Créer les Produits
|
|
||||||
|
|
||||||
Dans le Dashboard Stripe, allez dans **Products** et créez les produits suivants :
|
|
||||||
|
|
||||||
| Produit | Description |
|
|
||||||
|---------|-------------|
|
|
||||||
| **Xpeditis Starter** | Plan Starter - Jusqu'à 5 utilisateurs |
|
|
||||||
| **Xpeditis Pro** | Plan Pro - Jusqu'à 20 utilisateurs |
|
|
||||||
| **Xpeditis Enterprise** | Plan Enterprise - Utilisateurs illimités |
|
|
||||||
|
|
||||||
### 2.2 Créer les Prix
|
|
||||||
|
|
||||||
Pour chaque produit, créez 2 prix (mensuel et annuel) :
|
|
||||||
|
|
||||||
#### Starter
|
|
||||||
| Type | Prix | Récurrence |
|
|
||||||
|------|------|------------|
|
|
||||||
| Mensuel | 49 EUR | /mois |
|
|
||||||
| Annuel | 470 EUR | /an (~20% de réduction) |
|
|
||||||
|
|
||||||
#### Pro
|
|
||||||
| Type | Prix | Récurrence |
|
|
||||||
|------|------|------------|
|
|
||||||
| Mensuel | 149 EUR | /mois |
|
|
||||||
| Annuel | 1430 EUR | /an (~20% de réduction) |
|
|
||||||
|
|
||||||
#### Enterprise
|
|
||||||
| Type | Prix | Récurrence |
|
|
||||||
|------|------|------------|
|
|
||||||
| Mensuel | Prix personnalisé | /mois |
|
|
||||||
| Annuel | Prix personnalisé | /an |
|
|
||||||
|
|
||||||
### 2.3 Récupérer les Price IDs
|
|
||||||
|
|
||||||
Après avoir créé les prix, notez les **Price IDs** (format: `price_xxxxx`) pour chaque prix.
|
|
||||||
|
|
||||||
## 3. Configuration du Webhook
|
|
||||||
|
|
||||||
### 3.1 Créer le Webhook Endpoint
|
|
||||||
|
|
||||||
1. Allez dans **Developers > Webhooks**
|
|
||||||
2. Cliquez sur **Add endpoint**
|
|
||||||
3. Configurez :
|
|
||||||
- **Endpoint URL**: `https://votre-domaine.com/api/v1/subscriptions/webhook`
|
|
||||||
- **Events to send**: Sélectionnez les événements suivants :
|
|
||||||
- `checkout.session.completed`
|
|
||||||
- `customer.subscription.created`
|
|
||||||
- `customer.subscription.updated`
|
|
||||||
- `customer.subscription.deleted`
|
|
||||||
- `invoice.payment_failed`
|
|
||||||
|
|
||||||
4. Cliquez sur **Add endpoint**
|
|
||||||
5. Notez le **Webhook signing secret** (format: `whsec_xxxxx`)
|
|
||||||
|
|
||||||
### 3.2 Test Local avec Stripe CLI
|
|
||||||
|
|
||||||
Pour tester les webhooks en local :
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Installer Stripe CLI
|
|
||||||
brew install stripe/stripe-cli/stripe
|
|
||||||
|
|
||||||
# Se connecter à Stripe
|
|
||||||
stripe login
|
|
||||||
|
|
||||||
# Écouter les webhooks et les transférer en local
|
|
||||||
stripe listen --forward-to localhost:4000/api/v1/subscriptions/webhook
|
|
||||||
|
|
||||||
# Le CLI affichera le webhook secret à utiliser localement
|
|
||||||
# > Ready! Your webhook signing secret is whsec_xxxxx
|
|
||||||
```
|
|
||||||
|
|
||||||
## 4. Variables d'environnement
|
|
||||||
|
|
||||||
Ajoutez ces variables dans votre fichier `.env` du backend :
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Clés API Stripe
|
|
||||||
STRIPE_SECRET_KEY=sk_test_xxxxx # Clé secrète (test ou live)
|
|
||||||
STRIPE_WEBHOOK_SECRET=whsec_xxxxx # Secret du webhook
|
|
||||||
|
|
||||||
# Price IDs (créés dans le Dashboard)
|
|
||||||
STRIPE_STARTER_MONTHLY_PRICE_ID=price_xxxxx
|
|
||||||
STRIPE_STARTER_YEARLY_PRICE_ID=price_xxxxx
|
|
||||||
STRIPE_PRO_MONTHLY_PRICE_ID=price_xxxxx
|
|
||||||
STRIPE_PRO_YEARLY_PRICE_ID=price_xxxxx
|
|
||||||
STRIPE_ENTERPRISE_MONTHLY_PRICE_ID=price_xxxxx
|
|
||||||
STRIPE_ENTERPRISE_YEARLY_PRICE_ID=price_xxxxx
|
|
||||||
|
|
||||||
# URL Frontend (pour les redirections)
|
|
||||||
FRONTEND_URL=http://localhost:3000
|
|
||||||
```
|
|
||||||
|
|
||||||
## 5. Configurer le Customer Portal
|
|
||||||
|
|
||||||
Le Customer Portal permet aux clients de gérer leur abonnement (changer de plan, annuler, mettre à jour le paiement).
|
|
||||||
|
|
||||||
1. Allez dans **Settings > Billing > Customer portal**
|
|
||||||
2. Activez les options souhaitées :
|
|
||||||
- [x] Allow customers to update their payment methods
|
|
||||||
- [x] Allow customers to update subscriptions
|
|
||||||
- [x] Allow customers to cancel subscriptions
|
|
||||||
- [x] Show invoice history
|
|
||||||
|
|
||||||
3. Configurez les produits autorisés dans le portal
|
|
||||||
|
|
||||||
## 6. Mode Test vs Production
|
|
||||||
|
|
||||||
### Mode Test (Développement)
|
|
||||||
- Utilisez `sk_test_xxxxx` comme clé secrète
|
|
||||||
- Les paiements ne sont pas réels
|
|
||||||
- Utilisez les cartes de test Stripe :
|
|
||||||
- Succès: `4242 4242 4242 4242`
|
|
||||||
- Échec: `4000 0000 0000 0002`
|
|
||||||
- 3D Secure: `4000 0025 0000 3155`
|
|
||||||
|
|
||||||
### Mode Production
|
|
||||||
- Utilisez `sk_live_xxxxx` comme clé secrète
|
|
||||||
- Activez le mode live dans le Dashboard
|
|
||||||
- Assurez-vous d'avoir complété la vérification de compte
|
|
||||||
|
|
||||||
## 7. Flux d'abonnement
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────┐
|
|
||||||
│ Page Subscription│
|
|
||||||
│ (Frontend) │
|
|
||||||
└────────┬────────┘
|
|
||||||
│ Clic "Upgrade"
|
|
||||||
▼
|
|
||||||
┌─────────────────┐
|
|
||||||
│ POST /checkout │
|
|
||||||
│ (Backend) │
|
|
||||||
└────────┬────────┘
|
|
||||||
│ Crée Checkout Session
|
|
||||||
▼
|
|
||||||
┌─────────────────┐
|
|
||||||
│ Stripe Checkout │
|
|
||||||
│ (Stripe) │
|
|
||||||
└────────┬────────┘
|
|
||||||
│ Paiement réussi
|
|
||||||
▼
|
|
||||||
┌─────────────────┐
|
|
||||||
│ Webhook │
|
|
||||||
│ checkout. │
|
|
||||||
│ session.completed│
|
|
||||||
└────────┬────────┘
|
|
||||||
│ Met à jour la subscription
|
|
||||||
▼
|
|
||||||
┌─────────────────┐
|
|
||||||
│ Base de données │
|
|
||||||
│ (PostgreSQL) │
|
|
||||||
└─────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## 8. Gestion des erreurs
|
|
||||||
|
|
||||||
### Paiement échoué
|
|
||||||
- Le webhook `invoice.payment_failed` est déclenché
|
|
||||||
- L'abonnement passe en statut `PAST_DUE`
|
|
||||||
- L'utilisateur est informé et peut mettre à jour son moyen de paiement
|
|
||||||
|
|
||||||
### Annulation
|
|
||||||
- Via le Customer Portal ou l'API
|
|
||||||
- L'abonnement reste actif jusqu'à la fin de la période
|
|
||||||
- À la fin de la période, le webhook `customer.subscription.deleted` est déclenché
|
|
||||||
- L'organisation repasse au plan FREE
|
|
||||||
|
|
||||||
## 9. Vérification
|
|
||||||
|
|
||||||
### Checklist de configuration
|
|
||||||
|
|
||||||
- [ ] Produits créés dans Stripe Dashboard
|
|
||||||
- [ ] Prix créés (mensuel + annuel pour chaque plan)
|
|
||||||
- [ ] Webhook endpoint configuré
|
|
||||||
- [ ] Customer Portal configuré
|
|
||||||
- [ ] Variables d'environnement ajoutées au `.env`
|
|
||||||
- [ ] Test avec Stripe CLI en local
|
|
||||||
- [ ] Test d'un paiement complet (checkout → webhook)
|
|
||||||
|
|
||||||
### Test manuel
|
|
||||||
|
|
||||||
1. Lancez le backend et le frontend
|
|
||||||
2. Connectez-vous en tant qu'ADMIN
|
|
||||||
3. Allez dans Settings > Subscription
|
|
||||||
4. Cliquez sur "Upgrade" sur un plan payant
|
|
||||||
5. Utilisez la carte de test `4242 4242 4242 4242`
|
|
||||||
6. Vérifiez que le plan est mis à jour dans la base de données
|
|
||||||
7. Vérifiez que les licences sont correctement comptées
|
|
||||||
|
|
||||||
## 10. Commandes utiles
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Voir les webhooks reçus
|
|
||||||
stripe events list --limit 10
|
|
||||||
|
|
||||||
# Déclencher un webhook manuellement
|
|
||||||
stripe trigger checkout.session.completed
|
|
||||||
|
|
||||||
# Voir les logs
|
|
||||||
stripe logs tail
|
|
||||||
```
|
|
||||||
|
|
||||||
## Support
|
|
||||||
|
|
||||||
Pour toute question sur Stripe :
|
|
||||||
- Documentation Stripe : https://stripe.com/docs
|
|
||||||
- Support Stripe : https://support.stripe.com
|
|
||||||
Loading…
Reference in New Issue
Block a user