Compare commits

...

13 Commits

Author SHA1 Message Date
David-Henri ARNAUD
2158031bbe fix license2 2025-10-02 17:18:26 +02:00
David-Henri ARNAUD
1e544fffab Merge branch 'folder_export2' into feature_license2 2025-10-01 14:36:26 +02:00
David-Henri ARNAUD
cf5f4e74a1 Merge branch 'feature_devis2' into folder_export2 2025-10-01 10:27:25 +02:00
David-Henri ARNAUD
c0b1548226 fix feature devis 2025-10-01 10:25:11 +02:00
David
da8da492d2 feature test license and stripe abonnement 2025-09-16 16:41:51 +02:00
David
b3ed387197 feature a test 2025-09-15 14:41:34 +02:00
David
f31f1b6c69 feature devis 2025-09-12 22:44:19 +02:00
David
d1be066a20 Merge branch 'feature_login' into feature_devis 2025-09-12 12:27:30 +02:00
David
6b832ab4ca fix all error for package for login 2025-09-12 12:26:58 +02:00
David
f86389cb84 fix login 2025-09-12 11:47:51 +02:00
David
cb2dcf4d3a feature login 2025-09-12 11:41:46 +02:00
David
30ddb9b631 fix conf 2025-09-01 16:14:02 +02:00
David
c4356adcb2 feature login 2025-09-01 15:58:08 +02:00
204 changed files with 19084 additions and 633 deletions

View File

@ -0,0 +1,49 @@
{
"permissions": {
"allow": [
"Bash(../../mvnw clean compile)",
"Bash(do sed -i '/^@Slf4j$/{ N; s/@Slf4j\\n@Slf4j/@Slf4j/; }' \"$file\")",
"Bash(../../mvnw clean install -DskipTests)",
"Bash(../mvnw clean compile)",
"Bash(for file in InvoiceLineItemMapper.java InvoiceMapper.java SubscriptionMapper.java PaymentEventMapper.java PaymentMethodMapper.java)",
"Bash(do if [ -f \"$file\" ])",
"Bash(then mv \"$file\" \"$file.disabled\")",
"Bash(fi)",
"Bash(../mvnw clean install -DskipTests)",
"Bash(taskkill:*)",
"Bash(1)",
"Bash(tee:*)",
"Bash(../../../../../../mvnw clean install -DskipTests -pl domain/service)",
"Bash(for service in PlanServiceImpl SubscriptionServiceImpl)",
"Bash(do if [ -f \"$service.java\" ])",
"Bash(then mv \"$service.java\" \"$service.java.disabled\")",
"Bash(timeout:*)",
"Bash(./mvnw spring-boot:run:*)",
"Bash(mv:*)",
"Bash(./mvnw:*)",
"Bash(curl:*)",
"Bash(netstat:*)",
"Bash(findstr:*)",
"Bash(for:*)",
"Bash(then newname=\"$file%.disabled\")",
"Bash(if [ ! -f \"$newname\" ])",
"Bash(then mv \"$file\" \"$newname\")",
"Bash(echo:*)",
"Bash(done)",
"Bash(__NEW_LINE__ echo \"2. Health endpoint:\")",
"Bash(__NEW_LINE__ echo \"3. Users endpoint (protected):\")",
"Bash(__NEW_LINE__ echo \"4. Companies endpoint (protected):\")",
"Bash(__NEW_LINE__ echo \"5. Documents endpoint (protected):\")",
"Bash(__NEW_LINE__ echo \"6. Export folders endpoint (protected):\")",
"Bash(__NEW_LINE__ echo \"7. Quotes endpoint (protected):\")",
"Bash(then newname=\"$file%.tmp\")",
"Bash(TOKEN=\"eyJhbGciOiJIUzM4NCJ9.eyJzdWIiOiJhZG1pbiIsImlhdCI6MTc1OTQxNjE5MywiZXhwIjoxNzU5NTAyNTkzfQ.sWjlJI2taGPmERcWNCao77i1H8JJRst7GovKKvrMSoh0qIVyX5QIHeG2iLxfPisy\")",
"Bash(jq:*)",
"Bash(find:*)",
"Bash(bash:*)",
"Bash(TOKEN=\"eyJhbGciOiJIUzM4NCJ9.eyJzdWIiOiJ0ZXN0YWRtaW4iLCJpYXQiOjE3NTk0MTgxMzIsImV4cCI6MTc1OTUwNDUzMn0.0wKu5BTIEzPwDohKTfh7LgAJkujKynKU_176dADEzn0a_Ho81J_NjubD54P1lO_n\")"
],
"deny": [],
"ask": []
}
}

166
.env.example Normal file
View File

@ -0,0 +1,166 @@
# ===========================================
# XPEDITIS Backend Environment Configuration
# Copy this file to .env and fill in your values
# ===========================================
# ===========================================
# SERVER CONFIGURATION
# ===========================================
SERVER_PORT=8080
# ===========================================
# DATABASE CONFIGURATION
# ===========================================
# Development Database (H2) - No changes needed
SPRING_H2_CONSOLE_ENABLED=false
SPRING_H2_DATASOURCE_URL=jdbc:h2:mem:xpeditis;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
SPRING_H2_DATASOURCE_DRIVER_CLASS_NAME=org.h2.Driver
SPRING_H2_DATASOURCE_USERNAME=sa
SPRING_H2_DATASOURCE_PASSWORD=
# Production Database (MySQL) - FILL IN YOUR VALUES
SPRING_DATASOURCE_URL=jdbc:mysql://your-mysql-host:3306/xpeditis?useSSL=false&serverTimezone=UTC
SPRING_DATASOURCE_USERNAME=your_mysql_username
SPRING_DATASOURCE_PASSWORD=your_mysql_password
SPRING_DATASOURCE_DRIVER_CLASS_NAME=com.mysql.cj.jdbc.Driver
# ===========================================
# JPA/HIBERNATE CONFIGURATION
# ===========================================
SPRING_JPA_SHOW_SQL=false
SPRING_JPA_FORMAT_SQL=false
SPRING_JPA_DATABASE_PLATFORM_H2=org.hibernate.dialect.H2Dialect
SPRING_JPA_DATABASE_PLATFORM_MYSQL=org.hibernate.dialect.MySQLDialect
# Development
SPRING_JPA_HIBERNATE_DDL_AUTO_DEV=create-drop
SPRING_JPA_DEFER_DATASOURCE_INITIALIZATION_DEV=true
# Production
SPRING_JPA_HIBERNATE_DDL_AUTO_PROD=validate
SPRING_JPA_DEFER_DATASOURCE_INITIALIZATION_PROD=false
# ===========================================
# FLYWAY CONFIGURATION
# ===========================================
# Development
SPRING_FLYWAY_ENABLED_DEV=false
# Production
SPRING_FLYWAY_ENABLED_PROD=true
SPRING_FLYWAY_LOCATIONS=classpath:db/migration/structure,classpath:db/migration/data
SPRING_FLYWAY_VALIDATE_ON_MIGRATE=true
SPRING_FLYWAY_BASELINE_ON_MIGRATE=true
SPRING_FLYWAY_BASELINE_VERSION=0
SPRING_FLYWAY_DEFAULT_SCHEMA=leblr
# ===========================================
# EMAIL CONFIGURATION
# ===========================================
# Development (Mailtrap) - FILL IN YOUR MAILTRAP CREDENTIALS
SPRING_MAIL_HOST_DEV=sandbox.smtp.mailtrap.io
SPRING_MAIL_PORT_DEV=2525
SPRING_MAIL_USERNAME_DEV=your_mailtrap_username
SPRING_MAIL_PASSWORD_DEV=your_mailtrap_password
APPLICATION_EMAIL_FROM_DEV=noreply@xpeditis.local
# Production (OVH or your SMTP provider) - FILL IN YOUR VALUES
SPRING_MAIL_PROTOCOL_PROD=smtp
SPRING_MAIL_HOST_PROD=your-smtp-host
SPRING_MAIL_PORT_PROD=587
SPRING_MAIL_USERNAME_PROD=your-email@domain.com
SPRING_MAIL_PASSWORD_PROD=your_email_password
APPLICATION_EMAIL_FROM_PROD=your-email@domain.com
# Email Properties
SPRING_MAIL_SMTP_AUTH=true
SPRING_MAIL_SMTP_STARTTLS_ENABLE=true
SPRING_MAIL_SMTP_SSL_TRUST=*
SPRING_MAIL_SMTP_CONNECTION_TIMEOUT=5000
SPRING_MAIL_SMTP_TIMEOUT=3000
SPRING_MAIL_SMTP_WRITE_TIMEOUT=5000
# ===========================================
# OAUTH2 / GOOGLE CONFIGURATION
# ===========================================
# FILL IN YOUR GOOGLE OAUTH2 CREDENTIALS
GOOGLE_CLIENT_ID=your-google-client-id-from-console
GOOGLE_CLIENT_SECRET=your-google-client-secret-from-console
# Development
OAUTH2_REDIRECT_URI_DEV=http://localhost:8080/login/oauth2/code/google
# Production - FILL IN YOUR DOMAIN
OAUTH2_REDIRECT_URI_PROD=https://your-domain.com/login/oauth2/code/google
# Google OAuth2 URLs (standard - don't change)
GOOGLE_AUTHORIZATION_URI=https://accounts.google.com/o/oauth2/v2/auth
GOOGLE_TOKEN_URI=https://oauth2.googleapis.com/token
GOOGLE_USER_INFO_URI=https://www.googleapis.com/oauth2/v2/userinfo
GOOGLE_USER_NAME_ATTRIBUTE=sub
GOOGLE_OAUTH2_SCOPE=openid,email,profile
# ===========================================
# SECURITY / JWT CONFIGURATION
# ===========================================
# GENERATE A SECURE SECRET KEY FOR PRODUCTION
JWT_SECRET_KEY=your-very-secure-jwt-secret-key-here-make-it-long-and-random
JWT_EXPIRATION=86400000
JWT_REFRESH_TOKEN_EXPIRATION=604800000
# CSRF Configuration
APPLICATION_CSRF_ENABLED_DEV=false
APPLICATION_CSRF_ENABLED_PROD=true
# ===========================================
# APPLICATION SPECIFIC CONFIGURATION
# ===========================================
# OAuth2 Settings
APPLICATION_OAUTH2_GOOGLE_ENABLED=true
# License Configuration
APPLICATION_LICENSE_TRIAL_DURATION_DAYS=30
APPLICATION_LICENSE_TRIAL_MAX_USERS=5
APPLICATION_LICENSE_BASIC_MAX_USERS=50
APPLICATION_LICENSE_PREMIUM_MAX_USERS=200
APPLICATION_LICENSE_ENTERPRISE_MAX_USERS=1000
# ===========================================
# FILE UPLOAD CONFIGURATION
# ===========================================
FILE_UPLOAD_DIR=/upload
SPRING_SERVLET_MULTIPART_ENABLED=true
SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE=50MB
SPRING_SERVLET_MULTIPART_MAX_REQUEST_SIZE=50MB
# ===========================================
# APPLICATION METADATA
# ===========================================
SPRING_APPLICATION_NAME=XPEDITIS Backend
SPRING_APPLICATION_VERSION=0.0.1-SNAPSHOT
# ===========================================
# ACTIVE PROFILES
# ===========================================
# Use 'dev' for development, 'prod' for production
SPRING_PROFILES_ACTIVE=dev
# ===========================================
# LOGGING CONFIGURATION
# ===========================================
LOGGING_LEVEL_ROOT=INFO
LOGGING_LEVEL_SPRINGFRAMEWORK_BOOT_AUTOCONFIGURE=OFF
LOGGING_LEVEL_COMMONS_REQUEST_LOGGING_FILTER=INFO
LOGGING_LEVEL_HIBERNATE_SQL=OFF
LOGGING_LEVEL_HIBERNATE_TYPE=OFF
# ===========================================
# DEVELOPMENT ONLY
# ===========================================
# Uncomment for development debugging
# SPRING_JPA_SHOW_SQL=true
# SPRING_JPA_FORMAT_SQL=true
# LOGGING_LEVEL_ROOT=DEBUG

300
CLAUDE.md Normal file
View File

@ -0,0 +1,300 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Xpeditis is a Spring Boot-based logistics/shipping application built with a modular Maven architecture. The project follows Hexagonal Architecture (Clean Architecture) principles with clear separation of concerns.
## Architecture
### Modular Structure
The project is organized into Maven modules:
- **bootstrap**: Main application entry point and configuration (XpeditisApplication.java)
- **application**: REST controllers and web layer (AuthenticationRestController, UserRestController)
- **domain**: Core business logic split into:
- **api**: Service interfaces (UserService, AuthenticationService, etc.)
- **data**: DTOs and exceptions (UserAccount, AuthenticationRequest/Response)
- **service**: Service implementations (UserServiceImpl, AuthenticationServiceImpl)
- **spi**: Repository interfaces (UserRepository, AuthenticationRepository)
- **infrastructure**: Data access layer with JPA entities, repositories, and external integrations
- **common**: Shared utilities (CommonUtil)
### Key Technologies
- Spring Boot 3.4.1 with Java 23
- Spring Security with JWT authentication
- JPA/Hibernate for data persistence
- MapStruct for entity mapping
- Lombok for boilerplate reduction
- Flyway for database migrations
- MySQL (prod) / H2 (dev) databases
## Development Commands
### Build and Test
```bash
# Clean build and install all modules
./mvnw clean install
# Run tests
./mvnw test
# Run single test class
./mvnw test -Dtest=UserServiceImplTest
# Run specific test method
./mvnw test -Dtest=UserServiceImplTest#shouldCreateUser
```
### Database Management
```bash
# Development (H2) - migrate database
./mvnw clean install flyway:migrate '-Dflyway.configFiles=flyway-h2.conf' -Pdev
# Production (MySQL) - migrate database
./mvnw clean install flyway:migrate -Pprod
# Start application with dev profile (default)
./mvnw spring-boot:run
# Start with production profile
./mvnw spring-boot:run -Pprod
```
### Docker Operations
```bash
# Build and start services with Docker Compose
docker-compose up --build
# Start database only
docker-compose up db
# View logs
docker-compose logs -f back
```
## Development Profiles
- **dev** (default): Uses H2 in-memory database, simplified configuration
- **prod**: Uses MySQL database, includes Flyway migrations, production-ready settings
## Key Configuration Files
- `bootstrap/src/main/resources/application.yml`: Main configuration
- `bootstrap/src/main/resources/application-dev.yml`: Development overrides
- `bootstrap/src/main/resources/application-prod.yml`: Production overrides
- `infrastructure/flyway.conf`: Database migration configuration
- `compose.yml`: Docker services configuration
## API Documentation
Swagger UI is available at: http://localhost:8080/swagger-ui.html
## Security Implementation
The application uses JWT-based authentication with:
- JWT token generation and validation (JwtUtil)
- Security configuration (SecurityConfiguration)
- Authentication filter (JwtAuthenticationFilter)
- Logout service (LogoutService)
- Role-based access control with Permission enum
## Testing Strategy
- Unit tests for service layer implementations (AddressServiceImplTest, etc.)
- Repository tests for data access layer (AddressJpaRepositoryTest, etc.)
- Integration tests in bootstrap module (LeBlrApplicationTests)
- Use AssertJ for fluent assertions
## Database Schema
Database migrations are in `infrastructure/src/main/resources/db/migration/`:
- `structure/V1__INIT_DB_SCHEMA.sql`: Initial schema
- `data/V1.1__INIT_DB_DATA.sql`: Initial data
## Logging
Structured logging configuration in `logback-spring.xml` with:
- Production logs written to `./logs/prod/`
- Request logging via CommonsRequestLoggingFilter
- Configurable log levels per package
## 🏗️ Checklist Architecture Hexagonale - Étapes de Validation Pré-Codage
### 📋 Phase 1 : Analyse et Compréhension du Contexte
#### ✅ 1.1 Identification du Domaine Métier
- [ ] Clarifier le domaine d'activité : Quel est le métier principal ?
- [ ] Identifier les entités métier principales : Quels sont les objets centraux ?
- [ ] Définir les règles de gestion : Quelles sont les contraintes business ?
- [ ] Mapper les cas d'usage : Que doit faire l'application ?
#### ✅ 1.2 Analyse des Besoins d'Intégration
- [ ] Identifier les acteurs externes : Qui utilise l'application ?
- [ ] Répertorier les services externes : BDD, APIs, systèmes de fichiers ?
- [ ] Définir les interfaces d'entrée : REST, GraphQL, CLI ?
- [ ] Spécifier les interfaces de sortie : Persistance, notifications, etc.
### 📐 Phase 2 : Conception Architecturale
#### ✅ 2.1 Structure des Modules Maven
- [ ] Valider la structure 4 modules :
- `domain` (cœur hexagonal)
- `application` (couche application)
- `infrastructure` (couche infrastructure)
- `bootstrap` (module de lancement)
#### ✅ 2.2 Définition des Ports
- [ ] Identifier les Ports API (interfaces exposées par le domaine) :
- Services métier que le domaine expose
- Interfaces appelées par les adaptateurs d'entrée
- [ ] Identifier les Ports SPI (interfaces requises par le domaine) :
- Repositories pour la persistance
- Services externes (notifications, intégrations)
- Interfaces que le domaine définit mais n'implémente pas
#### ✅ 2.3 Conception des Adaptateurs
- [ ] Adaptateurs d'Entrée (Driving) :
- Contrôleurs REST
- Interfaces utilisateur
- Tests automatisés
- [ ] Adaptateurs de Sortie (Driven) :
- Implémentations des repositories
- Clients pour services externes
- Systèmes de fichiers/messaging
### 🏛️ Phase 3 : Architecture en Couches
#### ✅ 3.1 Module Domain (Cœur)
- [ ] Vérifier l'isolation complète : Aucune dépendance externe
- [ ] Structure du package :
```
com.{project}.domain/
├── model/ # Entités métier
├── service/ # Services du domaine
├── port/
│ ├── in/ # Ports d'entrée (API)
│ └── out/ # Ports de sortie (SPI)
└── exception/ # Exceptions métier
```
#### ✅ 3.2 Module Application
- [ ] Responsabilités claires : Exposition des services
- [ ] Structure du package :
```
com.{project}.application/
├── controller/ # Contrôleurs REST
├── dto/ # Data Transfer Objects
├── mapper/ # Mappers DTO ↔ Domain
└── config/ # Configuration Spring
```
#### ✅ 3.3 Module Infrastructure
- [ ] Implémentation des SPI : Tous les ports de sortie
- [ ] Structure du package :
```
com.{project}.infrastructure/
├── adapter/
│ ├── in/ # Adaptateurs d'entrée (si applicable)
│ └── out/ # Adaptateurs de sortie
├── repository/ # Implémentations JPA
├── entity/ # Entités JPA
└── mapper/ # Mappers JPA ↔ Domain
```
#### ✅ 3.4 Module bootstrap
- [ ] Point d'entrée unique : Classe @SpringBootApplication
- [ ] Assemblage des dépendances : Configuration complète
- [ ] Dépendances vers tous les modules
### 🔧 Phase 4 : Validation Technique
#### ✅ 4.1 Gestion des Dépendances
- [ ] Module domain : Aucune dépendance externe (sauf tests)
- [ ] Module application : Dépend uniquement de domain
- [ ] Module infrastructure : Dépend uniquement de domain
- [ ] Module bootstrap : Dépend de tous les autres modules
#### ✅ 4.2 Respect des Patterns
- [ ] Domain-Driven Design : Entités, Value Objects, Aggregates
- [ ] SOLID Principles : SRP, DIP, ISP appliqués
- [ ] Repository Pattern : Abstraction de la persistance
- [ ] Clean Architecture : Règle de dépendance respectée
#### ✅ 4.3 Configuration Spring
- [ ] Injection de dépendance : Utilisation de @Configuration
- [ ] Éviter @Component dans le domaine
- [ ] Gestion des transactions : @Transactional dans l'application
### 🧪 Phase 5 : Stratégie de Test
#### ✅ 5.1 Tests par Couche
- [ ] Tests unitaires du domaine : Sans dépendances externes
- [ ] Tests d'intégration : Par adaptateur spécifique
- [ ] Tests end-to-end : Flux complets
- [ ] Mocking strategy : Interfaces bien définies
#### ✅ 5.2 Testabilité
- [ ] Isolation du domaine : Tests sans Spring Context
- [ ] Adaptateurs mockables : Interfaces clairement définies
- [ ] Données de test : Jeux de données cohérents
### 📝 Phase 6 : Naming et Conventions
#### ✅ 6.1 Conventions de Nommage
- [ ] Ports : Suffixe "Port" ou noms explicites d'interfaces
- [ ] Adaptateurs : Suffixe "Adapter"
- [ ] Services : Suffixe "Service"
- [ ] Repositories : Suffixe "Repository"
#### ✅ 6.2 Structure des Packages
- [ ] Cohérence : Nommage uniforme entre modules
- [ ] Lisibilité : Structure claire et intuitive
- [ ] Séparation : Concerns bien séparés
### 🎯 Phase 7 : Questions de Validation Finale
#### ✅ 7.1 Questions Critiques à Se Poser
- [ ] Le domaine est-il complètement isolé ?
- [ ] Les dépendances pointent-elles vers l'intérieur ?
- [ ] Peut-on tester le domaine sans Spring ?
- [ ] Peut-on changer de base de données sans impact sur le domaine ?
- [ ] Peut-on ajouter une nouvelle interface (GraphQL) facilement ?
#### ✅ 7.2 Validation des Flux
- [ ] Flux d'entrée : HTTP → Contrôleur → Service → Port API
- [ ] Flux de sortie : Port SPI → Adaptateur → Base/Service externe
- [ ] Mapping : DTO ↔ Domain ↔ JPA entities clairement défini
### 🚦 Phase 8 : Checklist Pré-Codage
#### ✅ 8.1 Avant de Commencer
- [ ] Architecture validée avec le demandeur
- [ ] Modules Maven structure approuvée
- [ ] Ports et Adaptateurs identifiés et documentés
- [ ] Stack technique confirmée (Spring Boot, H2, MapStruct)
- [ ] Stratégie de tests définie
#### ✅ 8.2 Ordre de Développement Recommandé
1. Module domain : Entités, services, ports
2. Module infrastructure : Adaptateurs de sortie, repositories
3. Module application : Contrôleurs, DTOs, mappers
4. Module bootstrap : Configuration, assemblage
5. Tests : Unitaires → Intégration → E2E
### 💡 Points d'Attention Spéciaux
#### ⚠️ Pièges à Éviter
- Dépendances circulaires entre modules
- Logique métier dans les adaptateurs
- Annotations Spring dans le domaine
- Couplage fort entre couches
- Tests qui nécessitent le contexte Spring pour le domaine
#### 🎯 Objectifs à Atteindre
- Domaine pur et testable isolément
- Flexibilité pour changer d'adaptateurs
- Maintenabilité et évolutivité
- Séparation claire des responsabilités
- Code réutilisable et modulaire

155
ENV_SETUP.md Normal file
View File

@ -0,0 +1,155 @@
# Environment Configuration Guide
## Overview
Ce projet utilise des variables d'environnement pour la configuration. Toutes les variables ont été centralisées dans des fichiers `.env`.
## Setup Instructions
### 1. Copy the example file
```bash
cp .env.example .env
```
### 2. Edit the `.env` file with your values
#### Required for Development:
- `GOOGLE_CLIENT_ID` - Votre Google OAuth2 Client ID
- `GOOGLE_CLIENT_SECRET` - Votre Google OAuth2 Client Secret
- `JWT_SECRET_KEY` - Clé secrète pour JWT (générez une clé sécurisée)
#### Required for Production:
- Database credentials (`SPRING_DATASOURCE_*`)
- Email configuration (`SPRING_MAIL_*`)
- Production OAuth2 redirect URI
- Secure JWT secret key
## Environment Profiles
### Development (`SPRING_PROFILES_ACTIVE=dev`)
- Uses H2 in-memory database
- Uses Mailtrap for email testing
- CSRF disabled
- Flyway disabled
### Production (`SPRING_PROFILES_ACTIVE=prod`)
- Uses MySQL database
- Uses production SMTP server
- CSRF enabled
- Flyway enabled for migrations
## Key Variables by Category
### 🗄️ Database
```bash
# Development (H2)
SPRING_H2_DATASOURCE_URL=jdbc:h2:mem:xpeditis;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
# Production (MySQL)
SPRING_DATASOURCE_URL=jdbc:mysql://localhost:3306/xpeditis
SPRING_DATASOURCE_USERNAME=your_username
SPRING_DATASOURCE_PASSWORD=your_password
```
### 📧 Email
```bash
# Development (Mailtrap)
SPRING_MAIL_HOST_DEV=sandbox.smtp.mailtrap.io
SPRING_MAIL_USERNAME_DEV=your_mailtrap_username
SPRING_MAIL_PASSWORD_DEV=your_mailtrap_password
# Production
SPRING_MAIL_HOST_PROD=your-smtp-host
SPRING_MAIL_USERNAME_PROD=your-email@domain.com
SPRING_MAIL_PASSWORD_PROD=your_password
```
### 🔐 Security & OAuth2
```bash
# JWT
JWT_SECRET_KEY=your-secure-secret-key
JWT_EXPIRATION=86400000
# Google OAuth2
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret
```
### 📋 License Limits
```bash
APPLICATION_LICENSE_TRIAL_MAX_USERS=5
APPLICATION_LICENSE_BASIC_MAX_USERS=50
APPLICATION_LICENSE_PREMIUM_MAX_USERS=200
APPLICATION_LICENSE_ENTERPRISE_MAX_USERS=1000
```
## Security Notes
### 🛡️ Important Security Practices:
1. **Never commit `.env` files** to version control
2. **Generate secure JWT secret keys** for production
3. **Use environment-specific secrets**
4. **Rotate keys regularly** in production
### 🔑 JWT Secret Key Generation:
```bash
# Generate a secure 256-bit key
openssl rand -hex 32
# Or use online generator (ensure HTTPS):
# https://generate-secret.vercel.app/32
```
## Usage in Application
### 🎯 Easy Startup Scripts
Use the provided scripts for easy development and production startup:
```bash
# Development mode (loads .env automatically)
./run-dev.sh
# Production mode (validates required variables)
./run-prod.sh
# Or traditional way
./mvnw spring-boot:run
```
### Spring Boot Configuration Loading Order:
1. System environment variables (highest priority)
2. `.env` file variables
3. `application-{profile}.yml` files
4. `application.yml` (lowest priority)
### 🔧 All YAML files now support .env variables:
- `application.yml` - Base configuration with .env support
- `application-dev.yml` - Development overrides with .env support
- `application-prod.yml` - Production overrides with .env support
## Docker Support
For Docker deployments, mount the `.env` file:
```bash
docker run -d \
--env-file .env \
-p 8080:8080 \
xpeditis-backend
```
## Troubleshooting
### Common Issues:
1. **Missing variables**: Check `.env.example` for required variables
2. **Database connection**: Verify database credentials and host
3. **Email not sending**: Check SMTP configuration
4. **OAuth2 not working**: Verify Google Console settings and redirect URIs
### Debugging:
```bash
# Enable debug logging
LOGGING_LEVEL_ROOT=DEBUG
# Show SQL queries
SPRING_JPA_SHOW_SQL=true
SPRING_JPA_FORMAT_SQL=true
```

View File

@ -1,7 +0,0 @@
# leblr-backend
Le BLR backend side<br>
SWAGGER UI : http://localhost:8080/swagger-ui.html<br>
<br>
.\mvnw clean install flyway:migrate -Pprod<br>
.\mvnw clean install flyway:migrate '-Dflyway.configFiles=flyway-h2.conf' -Pdev<br>

View File

@ -0,0 +1,983 @@
{
"info": {
"name": "Xpeditis API Collection",
"description": "Collection complète des endpoints pour l'API Xpeditis - Plateforme de shipping maritime et logistique",
"version": "1.0.0",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
},
"auth": {
"type": "bearer",
"bearer": [
{
"key": "token",
"value": "{{jwt_token}}",
"type": "string"
}
]
},
"variable": [
{
"key": "base_url",
"value": "http://localhost:8080",
"type": "string"
},
{
"key": "jwt_token",
"value": "",
"type": "string"
},
{
"key": "refresh_token",
"value": "",
"type": "string"
}
],
"item": [
{
"name": "🏠 Service Info",
"item": [
{
"name": "Get Service Information",
"request": {
"method": "GET",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"url": {
"raw": "{{base_url}}/",
"host": ["{{base_url}}"],
"path": [""]
}
},
"response": []
}
],
"description": "Endpoints pour récupérer les informations du service"
},
{
"name": "🔐 Authentication",
"item": [
{
"name": "Login",
"event": [
{
"listen": "test",
"script": {
"exec": [
"if (pm.response.code === 200) {",
" var jsonData = pm.response.json();",
" pm.collectionVariables.set('jwt_token', jsonData.accessToken);",
" pm.collectionVariables.set('refresh_token', jsonData.refreshToken);",
"}"
]
}
}
],
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"username\": \"user@example.com\",\n \"password\": \"Password123!\"\n}"
},
"url": {
"raw": "{{base_url}}/api/v1/auth/login",
"host": ["{{base_url}}"],
"path": ["api", "v1", "auth", "login"]
}
},
"response": []
},
{
"name": "Register",
"event": [
{
"listen": "test",
"script": {
"exec": [
"if (pm.response.code === 201) {",
" var jsonData = pm.response.json();",
" pm.collectionVariables.set('jwt_token', jsonData.accessToken);",
" pm.collectionVariables.set('refresh_token', jsonData.refreshToken);",
"}"
]
}
}
],
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"firstName\": \"John\",\n \"lastName\": \"Doe\",\n \"email\": \"john.doe@example.com\",\n \"username\": \"johndoe\",\n \"password\": \"Password123!\",\n \"confirmPassword\": \"Password123!\",\n \"phoneNumber\": \"+33123456789\",\n \"companyName\": \"Maritime Solutions Inc\",\n \"companyCountry\": \"France\",\n \"authProvider\": \"LOCAL\",\n \"privacyPolicyAccepted\": true\n}"
},
"url": {
"raw": "{{base_url}}/api/v1/auth/register",
"host": ["{{base_url}}"],
"path": ["api", "v1", "auth", "register"]
}
},
"response": []
},
{
"name": "Google OAuth",
"event": [
{
"listen": "test",
"script": {
"exec": [
"if (pm.response.code === 200) {",
" var jsonData = pm.response.json();",
" pm.collectionVariables.set('jwt_token', jsonData.accessToken);",
" pm.collectionVariables.set('refresh_token', jsonData.refreshToken);",
"}"
]
}
}
],
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"googleToken\": \"google_oauth_token_here\",\n \"companyName\": \"Optional Company Name\",\n \"phoneNumber\": \"+33123456789\"\n}"
},
"url": {
"raw": "{{base_url}}/api/v1/auth/google",
"host": ["{{base_url}}"],
"path": ["api", "v1", "auth", "google"]
}
},
"response": []
},
{
"name": "Refresh Token",
"event": [
{
"listen": "test",
"script": {
"exec": [
"if (pm.response.code === 200) {",
" var jsonData = pm.response.json();",
" pm.collectionVariables.set('jwt_token', jsonData.accessToken);",
" pm.collectionVariables.set('refresh_token', jsonData.refreshToken);",
"}"
]
}
}
],
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"refreshToken\": \"{{refresh_token}}\"\n}"
},
"url": {
"raw": "{{base_url}}/api/v1/auth/refresh",
"host": ["{{base_url}}"],
"path": ["api", "v1", "auth", "refresh"]
}
},
"response": []
},
{
"name": "Logout",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
},
{
"key": "Authorization",
"value": "Bearer {{jwt_token}}"
}
],
"url": {
"raw": "{{base_url}}/api/v1/auth/logout",
"host": ["{{base_url}}"],
"path": ["api", "v1", "auth", "logout"]
}
},
"response": []
}
],
"description": "Endpoints d'authentification et gestion des sessions JWT"
},
{
"name": "👤 Profile Management",
"item": [
{
"name": "Get Profile",
"request": {
"method": "GET",
"header": [
{
"key": "Content-Type",
"value": "application/json"
},
{
"key": "Authorization",
"value": "Bearer {{jwt_token}}"
}
],
"url": {
"raw": "{{base_url}}/api/v1/profile",
"host": ["{{base_url}}"],
"path": ["api", "v1", "profile"]
}
},
"response": []
},
{
"name": "Update Profile",
"request": {
"method": "PUT",
"header": [
{
"key": "Content-Type",
"value": "application/json"
},
{
"key": "Authorization",
"value": "Bearer {{jwt_token}}"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"firstName\": \"John Updated\",\n \"lastName\": \"Doe Updated\",\n \"phoneNumber\": \"+33987654321\",\n \"username\": \"johnupdated\"\n}"
},
"url": {
"raw": "{{base_url}}/api/v1/profile",
"host": ["{{base_url}}"],
"path": ["api", "v1", "profile"]
}
},
"response": []
}
],
"description": "Endpoints de gestion du profil utilisateur via ProfileController"
},
{
"name": "👥 User Management",
"item": [
{
"name": "Get User Profile (Alternative)",
"request": {
"method": "GET",
"header": [
{
"key": "Content-Type",
"value": "application/json"
},
{
"key": "Authorization",
"value": "Bearer {{jwt_token}}"
}
],
"url": {
"raw": "{{base_url}}/api/v1/users/profile",
"host": ["{{base_url}}"],
"path": ["api", "v1", "users", "profile"]
}
},
"response": []
},
{
"name": "Update User Profile (Alternative)",
"request": {
"method": "PUT",
"header": [
{
"key": "Content-Type",
"value": "application/json"
},
{
"key": "Authorization",
"value": "Bearer {{jwt_token}}"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"firstName\": \"John Updated\",\n \"lastName\": \"Doe Updated\",\n \"phoneNumber\": \"+33987654321\",\n \"username\": \"johnupdated\"\n}"
},
"url": {
"raw": "{{base_url}}/api/v1/users/profile",
"host": ["{{base_url}}"],
"path": ["api", "v1", "users", "profile"]
}
},
"response": []
},
{
"name": "Change Password",
"request": {
"method": "PUT",
"header": [
{
"key": "Content-Type",
"value": "application/json"
},
{
"key": "Authorization",
"value": "Bearer {{jwt_token}}"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"currentPassword\": \"Password123!\",\n \"newPassword\": \"NewPassword123!\",\n \"confirmationPassword\": \"NewPassword123!\"\n}"
},
"url": {
"raw": "{{base_url}}/api/v1/users/password",
"host": ["{{base_url}}"],
"path": ["api", "v1", "users", "password"]
}
},
"response": []
},
{
"name": "Delete Account",
"request": {
"method": "DELETE",
"header": [
{
"key": "Content-Type",
"value": "application/json"
},
{
"key": "Authorization",
"value": "Bearer {{jwt_token}}"
}
],
"url": {
"raw": "{{base_url}}/api/v1/users/account",
"host": ["{{base_url}}"],
"path": ["api", "v1", "users", "account"]
}
},
"response": []
},
{
"name": "List Users (Admin Only)",
"request": {
"method": "GET",
"header": [
{
"key": "Content-Type",
"value": "application/json"
},
{
"key": "Authorization",
"value": "Bearer {{jwt_token}}"
}
],
"url": {
"raw": "{{base_url}}/api/v1/users?page=0&size=10",
"host": ["{{base_url}}"],
"path": ["api", "v1", "users"],
"query": [
{
"key": "page",
"value": "0"
},
{
"key": "size",
"value": "10"
},
{
"key": "companyId",
"value": "uuid-company-id",
"disabled": true
}
]
}
},
"response": []
}
],
"description": "Endpoints de gestion des utilisateurs - profil, mot de passe, suppression de compte"
},
{
"name": "📋 Devis Transport",
"item": [
{
"name": "Calculer Devis (3 offres automatiques)",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"typeService\": \"EXPORT\",\n \"incoterm\": \"FOB\",\n \"typeLivraison\": \"PORTE_A_PORTE\",\n \"depart\": {\n \"ville\": \"Marseille\",\n \"codePostal\": \"13000\",\n \"pays\": \"France\",\n \"coordonneesGps\": \"43.2965,5.3698\"\n },\n \"arrivee\": {\n \"ville\": \"Shanghai\",\n \"codePostal\": \"200000\",\n \"pays\": \"Chine\",\n \"coordonneesGps\": \"31.2304,121.4737\"\n },\n \"douaneImportExport\": \"EXPORT\",\n \"eur1Import\": false,\n \"eur1Export\": true,\n \"colisages\": [\n {\n \"type\": \"PALETTE\",\n \"quantite\": 3,\n \"longueur\": 120.0,\n \"largeur\": 80.0,\n \"hauteur\": 160.0,\n \"poids\": 750.5,\n \"gerbable\": true\n },\n {\n \"type\": \"CAISSE\",\n \"quantite\": 2,\n \"longueur\": 100.0,\n \"largeur\": 60.0,\n \"hauteur\": 120.0,\n \"poids\": 450.2,\n \"gerbable\": false\n }\n ],\n \"marchandiseDangereuse\": {\n \"presente\": true,\n \"classe\": \"3\",\n \"numeroOnu\": \"UN1263\",\n \"description\": \"Peintures inflammables\"\n },\n \"manutentionParticuliere\": {\n \"hayon\": true,\n \"sangles\": true,\n \"couvertureThermique\": false,\n \"autres\": \"Manutention précautionneuse requise\"\n },\n \"produitsReglementes\": {\n \"alimentaire\": false,\n \"pharmaceutique\": false,\n \"autres\": null\n },\n \"servicesAdditionnels\": {\n \"rendezVousLivraison\": true,\n \"documentT1\": false,\n \"stopDouane\": true,\n \"assistanceExport\": true,\n \"assurance\": true,\n \"valeurDeclaree\": 15000.0\n },\n \"dateEnlevement\": \"2025-01-15\",\n \"dateLivraison\": \"2025-02-20\",\n \"nomClient\": \"Maritime Solutions SAS\",\n \"emailClient\": \"contact@maritime-solutions.fr\"\n}"
},
"url": {
"raw": "{{base_url}}/api/v1/devis/calculer",
"host": ["{{base_url}}"],
"path": ["api", "v1", "devis", "calculer"]
}
},
"response": []
},
{
"name": "Valider Demande de Devis",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"typeService\": \"EXPORT\",\n \"incoterm\": \"FOB\",\n \"depart\": {\n \"ville\": \"Marseille\",\n \"codePostal\": \"13000\",\n \"pays\": \"France\"\n },\n \"arrivee\": {\n \"ville\": \"Shanghai\",\n \"codePostal\": \"200000\",\n \"pays\": \"Chine\"\n },\n \"colisages\": [\n {\n \"type\": \"CAISSE\",\n \"quantite\": 2,\n \"poids\": 150.5,\n \"longueur\": 120.0,\n \"largeur\": 80.0,\n \"hauteur\": 100.0,\n \"gerbable\": false\n }\n ],\n \"marchandiseDangereuse\": null,\n \"nomClient\": \"Test Client\",\n \"emailClient\": \"test@test.com\"\n}"
},
"url": {
"raw": "{{base_url}}/api/v1/devis/valider",
"host": ["{{base_url}}"],
"path": ["api", "v1", "devis", "valider"]
}
},
"response": []
},
{
"name": "Calculer Devis - Exemple Simple",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"typeService\": \"EXPORT\",\n \"incoterm\": \"FOB\",\n \"depart\": {\n \"ville\": \"Marseille\",\n \"codePostal\": \"13000\",\n \"pays\": \"France\"\n },\n \"arrivee\": {\n \"ville\": \"Shanghai\",\n \"codePostal\": \"200000\",\n \"pays\": \"Chine\"\n },\n \"colisages\": [\n {\n \"type\": \"CAISSE\",\n \"quantite\": 2,\n \"poids\": 150.5,\n \"longueur\": 120.0,\n \"largeur\": 80.0,\n \"hauteur\": 100.0,\n \"gerbable\": false\n }\n ],\n \"marchandiseDangereuse\": null,\n \"nomClient\": \"Test Client\",\n \"emailClient\": \"test@test.com\"\n}"
},
"url": {
"raw": "{{base_url}}/api/v1/devis/calculer",
"host": ["{{base_url}}"],
"path": ["api", "v1", "devis", "calculer"]
}
},
"response": []
},
{
"name": "Calculer Devis - Avec Marchandises Dangereuses",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"typeService\": \"EXPORT\",\n \"incoterm\": \"FOB\",\n \"depart\": {\n \"ville\": \"Marseille\",\n \"codePostal\": \"13000\",\n \"pays\": \"France\"\n },\n \"arrivee\": {\n \"ville\": \"Hong Kong\",\n \"codePostal\": \"999077\",\n \"pays\": \"Hong Kong\"\n },\n \"colisages\": [\n {\n \"type\": \"PALETTE\",\n \"quantite\": 5,\n \"poids\": 890.0,\n \"longueur\": 120.0,\n \"largeur\": 80.0,\n \"hauteur\": 200.0,\n \"gerbable\": true\n }\n ],\n \"marchandiseDangereuse\": {\n \"presente\": true,\n \"classe\": \"3\",\n \"numeroOnu\": \"UN1263\",\n \"description\": \"Produits chimiques inflammables\"\n },\n \"servicesAdditionnels\": {\n \"assurance\": true,\n \"valeurDeclaree\": 25000.0,\n \"rendezVousLivraison\": true,\n \"assistanceExport\": true\n },\n \"nomClient\": \"Chemical Industries Ltd\",\n \"emailClient\": \"export@chemical-industries.com\"\n}"
},
"url": {
"raw": "{{base_url}}/api/v1/devis/calculer",
"host": ["{{base_url}}"],
"path": ["api", "v1", "devis", "calculer"]
}
},
"response": []
}
],
"description": "API de calcul automatisé de devis transport maritime - génère 3 offres (Rapide, Standard, Économique) basées sur les grilles tarifaires LESCHACO"
},
{
"name": "📦 SSC Export Folders",
"item": [
{
"name": "🔍 Rechercher Dossiers",
"request": {
"method": "GET",
"header": [
{
"key": "Content-Type",
"value": "application/json"
},
{
"key": "Authorization",
"value": "Bearer {{jwt_token}}"
}
],
"url": {
"raw": "{{base_url}}/api/v1/export-folders?page=0&size=20&sortBy=dateCreation&sortDir=DESC&statut=CREE&numeroDossier=EXP-2024",
"host": ["{{base_url}}"],
"path": ["api", "v1", "export-folders"],
"query": [
{
"key": "page",
"value": "0",
"description": "Numéro de page (0-indexed)"
},
{
"key": "size",
"value": "20",
"description": "Taille de page"
},
{
"key": "sortBy",
"value": "dateCreation",
"description": "Champ de tri"
},
{
"key": "sortDir",
"value": "DESC",
"description": "Direction de tri"
},
{
"key": "statut",
"value": "CREE",
"description": "Filtrer par statut",
"disabled": true
},
{
"key": "numeroDossier",
"value": "EXP-2024",
"description": "Recherche par numéro de dossier",
"disabled": true
},
{
"key": "companyId",
"value": "1",
"description": "Filtrer par entreprise (admin uniquement)",
"disabled": true
}
]
}
},
"response": [],
"protocolProfileBehavior": {
"disableBodyPruning": true
}
},
{
"name": "📋 Obtenir Dossier par ID",
"request": {
"method": "GET",
"header": [
{
"key": "Content-Type",
"value": "application/json"
},
{
"key": "Authorization",
"value": "Bearer {{jwt_token}}"
}
],
"url": {
"raw": "{{base_url}}/api/v1/export-folders/1",
"host": ["{{base_url}}"],
"path": ["api", "v1", "export-folders", "1"]
}
},
"response": []
},
{
"name": " Créer Dossier depuis Devis",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
},
{
"key": "Authorization",
"value": "Bearer {{jwt_token}}"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"quoteId\": 1,\n \"commentairesClient\": \"Demande de transport urgent pour livraison avant fin mars. Marchandises fragiles - manipulation soignée requise.\"\n}"
},
"url": {
"raw": "{{base_url}}/api/v1/export-folders",
"host": ["{{base_url}}"],
"path": ["api", "v1", "export-folders"]
}
},
"response": []
},
{
"name": "🔄 Mettre à jour Statut",
"request": {
"method": "PUT",
"header": [
{
"key": "Content-Type",
"value": "application/json"
},
{
"key": "Authorization",
"value": "Bearer {{jwt_token}}"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"newStatus\": \"DOCUMENTS_EN_ATTENTE\",\n \"comment\": \"Dossier validé, en attente des documents clients\"\n}"
},
"url": {
"raw": "{{base_url}}/api/v1/export-folders/1/status",
"host": ["{{base_url}}"],
"path": ["api", "v1", "export-folders", "1", "status"]
}
},
"response": []
},
{
"name": "📎 Uploader Document",
"request": {
"method": "POST",
"header": [
{
"key": "Authorization",
"value": "Bearer {{jwt_token}}"
}
],
"body": {
"mode": "formdata",
"formdata": [
{
"key": "file",
"type": "file",
"src": "/path/to/your/document.pdf"
},
{
"key": "typeDocumentId",
"value": "1",
"type": "text",
"description": "ID du type de document (1=Facture commerciale, 2=Liste de colisage, etc.)"
},
{
"key": "description",
"value": "Facture commerciale pour export maritime",
"type": "text",
"description": "Description optionnelle"
}
]
},
"url": {
"raw": "{{base_url}}/api/v1/export-folders/1/documents",
"host": ["{{base_url}}"],
"path": ["api", "v1", "export-folders", "1", "documents"]
}
},
"response": []
},
{
"name": "📑 Lister Documents",
"request": {
"method": "GET",
"header": [
{
"key": "Content-Type",
"value": "application/json"
},
{
"key": "Authorization",
"value": "Bearer {{jwt_token}}"
}
],
"url": {
"raw": "{{base_url}}/api/v1/export-folders/1/documents",
"host": ["{{base_url}}"],
"path": ["api", "v1", "export-folders", "1", "documents"]
}
},
"response": []
},
{
"name": "📜 Historique du Dossier",
"request": {
"method": "GET",
"header": [
{
"key": "Content-Type",
"value": "application/json"
},
{
"key": "Authorization",
"value": "Bearer {{jwt_token}}"
}
],
"url": {
"raw": "{{base_url}}/api/v1/export-folders/1/history?limit=50",
"host": ["{{base_url}}"],
"path": ["api", "v1", "export-folders", "1", "history"],
"query": [
{
"key": "limit",
"value": "50",
"description": "Nombre maximum d'entrées à retourner"
}
]
}
},
"response": []
},
{
"name": "🔐 Actions Autorisées",
"request": {
"method": "GET",
"header": [
{
"key": "Content-Type",
"value": "application/json"
},
{
"key": "Authorization",
"value": "Bearer {{jwt_token}}"
}
],
"url": {
"raw": "{{base_url}}/api/v1/export-folders/1/permissions",
"host": ["{{base_url}}"],
"path": ["api", "v1", "export-folders", "1", "permissions"]
}
},
"response": []
}
],
"description": "🚢 Système SSC de Gestion des Dossiers d'Export - Workflow complet de création, suivi et gestion documentaire pour les expéditions maritimes",
"auth": {
"type": "bearer",
"bearer": [
{
"key": "token",
"value": "{{jwt_token}}",
"type": "string"
}
]
}
},
{
"name": "📊 Grilles Tarifaires",
"item": [
{
"name": "📋 Lister Grilles Tarifaires",
"request": {
"method": "GET",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"url": {
"raw": "{{base_url}}/api/v1/grilles-tarifaires?page=0&size=20",
"host": ["{{base_url}}"],
"path": ["api", "v1", "grilles-tarifaires"],
"query": [
{
"key": "page",
"value": "0"
},
{
"key": "size",
"value": "20"
},
{
"key": "nom",
"value": "LESCHACO",
"disabled": true,
"description": "Filtrer par nom"
},
{
"key": "paysOrigine",
"value": "France",
"disabled": true,
"description": "Filtrer par pays d'origine"
}
]
}
},
"response": []
},
{
"name": "🔍 Obtenir Grille par ID",
"request": {
"method": "GET",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"url": {
"raw": "{{base_url}}/api/v1/grilles-tarifaires/1",
"host": ["{{base_url}}"],
"path": ["api", "v1", "grilles-tarifaires", "1"]
}
},
"response": []
},
{
"name": "✅ Valider Grille Tarifaire",
"request": {
"method": "POST",
"header": [
{
"key": "Authorization",
"value": "Bearer {{jwt_token}}"
}
],
"body": {
"mode": "formdata",
"formdata": [
{
"key": "file",
"type": "file",
"src": "/path/to/your/grille.xlsx",
"description": "Fichier Excel ou CSV contenant la grille tarifaire"
}
]
},
"url": {
"raw": "{{base_url}}/api/v1/grilles-tarifaires/validate",
"host": ["{{base_url}}"],
"path": ["api", "v1", "grilles-tarifaires", "validate"]
}
},
"response": []
},
{
"name": "📤 Import CSV",
"request": {
"method": "POST",
"header": [
{
"key": "Authorization",
"value": "Bearer {{jwt_token}}"
}
],
"body": {
"mode": "formdata",
"formdata": [
{
"key": "file",
"type": "file",
"src": "/path/to/your/grille.csv"
}
]
},
"url": {
"raw": "{{base_url}}/api/v1/grilles-tarifaires/import/csv",
"host": ["{{base_url}}"],
"path": ["api", "v1", "grilles-tarifaires", "import", "csv"]
}
},
"response": []
},
{
"name": "📤 Import Excel",
"request": {
"method": "POST",
"header": [
{
"key": "Authorization",
"value": "Bearer {{jwt_token}}"
}
],
"body": {
"mode": "formdata",
"formdata": [
{
"key": "file",
"type": "file",
"src": "/path/to/your/grille.xlsx"
}
]
},
"url": {
"raw": "{{base_url}}/api/v1/grilles-tarifaires/import/excel",
"host": ["{{base_url}}"],
"path": ["api", "v1", "grilles-tarifaires", "import", "excel"]
}
},
"response": []
},
{
"name": "📤 Import JSON",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
},
{
"key": "Authorization",
"value": "Bearer {{jwt_token}}"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"nom\": \"Grille LESCHACO 2025\",\n \"paysOrigine\": \"France\",\n \"paysDestination\": \"Chine\",\n \"portOrigine\": \"FRBOL\",\n \"portDestination\": \"CNSHA\",\n \"dateValiditeDebut\": \"2025-01-01\",\n \"dateValiditeFin\": \"2025-12-31\",\n \"tarifsFret\": [\n {\n \"poidsMin\": 0.0,\n \"poidsMax\": 100.0,\n \"prixParKg\": 2.50,\n \"prixForfaitaire\": 150.00\n },\n {\n \"poidsMin\": 100.0,\n \"poidsMax\": 500.0,\n \"prixParKg\": 2.20,\n \"prixForfaitaire\": 200.00\n }\n ],\n \"fraisAdditionnels\": [\n {\n \"type\": \"MANUTENTION\",\n \"montant\": 45.00,\n \"description\": \"Frais de manutention portuaire\"\n },\n {\n \"type\": \"DOCUMENTATION\",\n \"montant\": 25.00,\n \"description\": \"Frais de documentation\"\n }\n ],\n \"surchargesDangereuses\": [\n {\n \"classe\": \"3\",\n \"pourcentage\": 15.0,\n \"montantFixe\": 100.0,\n \"description\": \"Surcharge marchandises inflammables\"\n }\n ]\n}"
},
"url": {
"raw": "{{base_url}}/api/v1/grilles-tarifaires/import/json",
"host": ["{{base_url}}"],
"path": ["api", "v1", "grilles-tarifaires", "import", "json"]
}
},
"response": []
}
],
"description": "🏷️ Gestion des grilles tarifaires LESCHACO - Import, validation et consultation des tarifs pour le calcul automatisé des devis"
}
],
"event": [
{
"listen": "prerequest",
"script": {
"type": "text/javascript",
"exec": [
""
]
}
},
{
"listen": "test",
"script": {
"type": "text/javascript",
"exec": [
""
]
}
}
]
}

View File

@ -29,6 +29,14 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>

View File

@ -10,7 +10,7 @@ import org.springframework.web.bind.annotation.RestController;
import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE;
@RestController
@RequestMapping(name = "/",
@RequestMapping(value = "/",
produces = APPLICATION_JSON_VALUE)
public class IndexRestController {

View File

@ -0,0 +1,317 @@
package com.dh7789dev.xpeditis.controller;
import com.dh7789dev.xpeditis.dto.SubscriptionDto;
import com.dh7789dev.xpeditis.dto.request.CreateSubscriptionRequest;
import com.dh7789dev.xpeditis.dto.request.UpdateSubscriptionRequest;
import com.dh7789dev.xpeditis.port.in.SubscriptionService;
import com.dh7789dev.xpeditis.mapper.SubscriptionDtoMapper;
import com.dh7789dev.xpeditis.dto.app.Subscription;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* Contrôleur REST pour la gestion des abonnements
*/
@Slf4j
@RestController
@RequestMapping("/api/v1/subscriptions")
@RequiredArgsConstructor
@Tag(name = "Subscriptions", description = "API de gestion des abonnements Stripe")
public class SubscriptionController {
private final SubscriptionService subscriptionService;
private final SubscriptionDtoMapper dtoMapper;
// ===== CRÉATION D'ABONNEMENT =====
@PostMapping
@PreAuthorize("hasRole('ADMIN') or hasRole('COMPANY_ADMIN')")
@Operation(summary = "Créer un nouvel abonnement")
@ApiResponses({
@ApiResponse(responseCode = "201", description = "Abonnement créé avec succès"),
@ApiResponse(responseCode = "400", description = "Données invalides"),
@ApiResponse(responseCode = "403", description = "Accès refusé"),
@ApiResponse(responseCode = "409", description = "Abonnement déjà existant")
})
public ResponseEntity<SubscriptionDto> createSubscription(
@Valid @RequestBody CreateSubscriptionRequest request) {
log.info("Création d'abonnement pour l'entreprise {}, plan {}",
request.getCompanyId(), request.getPlanType());
try {
Subscription subscription = subscriptionService.createSubscription(
request.getCompanyId(),
request.getPlanType(),
request.getBillingCycle(),
request.getPaymentMethodId() != null ? request.getPaymentMethodId().toString() : null,
request.getStartTrial(),
request.getCustomTrialDays()
);
SubscriptionDto dto = dtoMapper.toDto(subscription);
log.info("Abonnement créé avec succès: {}", subscription.getStripeSubscriptionId());
return ResponseEntity.status(HttpStatus.CREATED).body(dto);
} catch (Exception e) {
log.error("Erreur lors de la création de l'abonnement: {}", e.getMessage(), e);
throw e;
}
}
// ===== CONSULTATION DES ABONNEMENTS =====
@GetMapping
@PreAuthorize("hasRole('ADMIN')")
@Operation(summary = "Lister tous les abonnements")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "Liste des abonnements"),
@ApiResponse(responseCode = "403", description = "Accès refusé")
})
public ResponseEntity<Page<SubscriptionDto.Summary>> getAllSubscriptions(
@Parameter(description = "Statut de l'abonnement") @RequestParam(required = false) String status,
@Parameter(description = "Cycle de facturation") @RequestParam(required = false) String billingCycle,
@Parameter(description = "ID du client Stripe") @RequestParam(required = false) String customerId,
Pageable pageable) {
log.debug("Récupération des abonnements - statut: {}, cycle: {}, client: {}",
status, billingCycle, customerId);
List<Subscription> subscriptions = subscriptionService.findSubscriptions(
status, billingCycle, customerId, pageable.getPageNumber(), pageable.getPageSize());
List<SubscriptionDto.Summary> dtos = subscriptions.stream()
.map(dtoMapper::toSummaryDto)
.collect(Collectors.toList());
Page<SubscriptionDto.Summary> result = new PageImpl<>(dtos, pageable, dtos.size());
return ResponseEntity.ok(result);
}
@GetMapping("/{subscriptionId}")
@PreAuthorize("hasRole('ADMIN') or (hasRole('COMPANY_ADMIN') and @subscriptionSecurity.canAccessSubscription(authentication, #subscriptionId))")
@Operation(summary = "Obtenir les détails d'un abonnement")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "Détails de l'abonnement"),
@ApiResponse(responseCode = "403", description = "Accès refusé"),
@ApiResponse(responseCode = "404", description = "Abonnement non trouvé")
})
public ResponseEntity<SubscriptionDto.Detailed> getSubscription(
@Parameter(description = "ID de l'abonnement") @PathVariable UUID subscriptionId) {
log.debug("Récupération de l'abonnement {}", subscriptionId);
Optional<Subscription> subscription = subscriptionService.findById(subscriptionId);
if (subscription.isEmpty()) {
log.warn("Abonnement {} non trouvé", subscriptionId);
return ResponseEntity.notFound().build();
}
SubscriptionDto.Detailed dto = dtoMapper.toDetailedDto(subscription.get());
return ResponseEntity.ok(dto);
}
// ===== MODIFICATION D'ABONNEMENT =====
@PutMapping("/{subscriptionId}")
@PreAuthorize("hasRole('ADMIN') or (hasRole('COMPANY_ADMIN') and @subscriptionSecurity.canAccessSubscription(authentication, #subscriptionId))")
@Operation(summary = "Modifier un abonnement")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "Abonnement modifié"),
@ApiResponse(responseCode = "400", description = "Données invalides"),
@ApiResponse(responseCode = "403", description = "Accès refusé"),
@ApiResponse(responseCode = "404", description = "Abonnement non trouvé")
})
public ResponseEntity<SubscriptionDto> updateSubscription(
@Parameter(description = "ID de l'abonnement") @PathVariable UUID subscriptionId,
@Valid @RequestBody UpdateSubscriptionRequest request) {
log.info("Modification de l'abonnement {} - nouveau plan: {}",
subscriptionId, request.getNewPlanType());
try {
Optional<Subscription> updated = Optional.empty();
if (request.getNewPlanType() != null || request.getNewBillingCycle() != null) {
updated = Optional.of(subscriptionService.changeSubscriptionPlan(
subscriptionId,
request.getNewPlanType(),
request.getNewBillingCycle(),
request.getEnableProration(),
request.getImmediateChange()
));
}
if (request.getNewPaymentMethodId() != null) {
subscriptionService.updatePaymentMethod(subscriptionId, request.getNewPaymentMethodId().toString());
if (updated.isEmpty()) {
updated = subscriptionService.findById(subscriptionId);
}
}
if (request.getCancelAtPeriodEnd() != null) {
if (request.getCancelAtPeriodEnd()) {
updated = Optional.of(subscriptionService.scheduleForCancellation(
subscriptionId, request.getCancellationReason()));
} else {
updated = Optional.of(subscriptionService.reactivateSubscription(subscriptionId));
}
}
if (updated.isEmpty()) {
log.warn("Aucune modification apportée à l'abonnement {}", subscriptionId);
return ResponseEntity.badRequest().build();
}
SubscriptionDto dto = dtoMapper.toDto(updated.get());
log.info("Abonnement {} modifié avec succès", subscriptionId);
return ResponseEntity.ok(dto);
} catch (Exception e) {
log.error("Erreur lors de la modification de l'abonnement {}: {}", subscriptionId, e.getMessage(), e);
throw e;
}
}
// ===== ACTIONS SPÉCIFIQUES =====
@PostMapping("/{subscriptionId}/cancel")
@PreAuthorize("hasRole('ADMIN') or (hasRole('COMPANY_ADMIN') and @subscriptionSecurity.canAccessSubscription(authentication, #subscriptionId))")
@Operation(summary = "Annuler un abonnement immédiatement")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "Abonnement annulé"),
@ApiResponse(responseCode = "403", description = "Accès refusé"),
@ApiResponse(responseCode = "404", description = "Abonnement non trouvé")
})
public ResponseEntity<SubscriptionDto> cancelSubscriptionImmediately(
@Parameter(description = "ID de l'abonnement") @PathVariable UUID subscriptionId,
@Parameter(description = "Raison de l'annulation") @RequestParam(required = false) String reason) {
log.info("Annulation immédiate de l'abonnement {} - raison: {}", subscriptionId, reason);
try {
Subscription subscription = subscriptionService.cancelSubscriptionImmediately(subscriptionId, reason);
SubscriptionDto dto = dtoMapper.toDto(subscription);
log.info("Abonnement {} annulé avec succès", subscriptionId);
return ResponseEntity.ok(dto);
} catch (Exception e) {
log.error("Erreur lors de l'annulation de l'abonnement {}: {}", subscriptionId, e.getMessage(), e);
throw e;
}
}
@PostMapping("/{subscriptionId}/reactivate")
@PreAuthorize("hasRole('ADMIN') or (hasRole('COMPANY_ADMIN') and @subscriptionSecurity.canAccessSubscription(authentication, #subscriptionId))")
@Operation(summary = "Réactiver un abonnement annulé")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "Abonnement réactivé"),
@ApiResponse(responseCode = "403", description = "Accès refusé"),
@ApiResponse(responseCode = "404", description = "Abonnement non trouvé"),
@ApiResponse(responseCode = "409", description = "Abonnement ne peut pas être réactivé")
})
public ResponseEntity<SubscriptionDto> reactivateSubscription(
@Parameter(description = "ID de l'abonnement") @PathVariable UUID subscriptionId) {
log.info("Réactivation de l'abonnement {}", subscriptionId);
try {
Subscription subscription = subscriptionService.reactivateSubscription(subscriptionId);
SubscriptionDto dto = dtoMapper.toDto(subscription);
log.info("Abonnement {} réactivé avec succès", subscriptionId);
return ResponseEntity.ok(dto);
} catch (Exception e) {
log.error("Erreur lors de la réactivation de l'abonnement {}: {}", subscriptionId, e.getMessage(), e);
throw e;
}
}
// ===== RECHERCHES SPÉCIALISÉES =====
@GetMapping("/company/{companyId}")
@PreAuthorize("hasRole('ADMIN') or (hasRole('COMPANY_ADMIN') and @companySecurity.canAccessCompany(authentication, #companyId))")
@Operation(summary = "Obtenir les abonnements d'une entreprise")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "Abonnements de l'entreprise"),
@ApiResponse(responseCode = "403", description = "Accès refusé")
})
public ResponseEntity<List<SubscriptionDto.Summary>> getCompanySubscriptions(
@Parameter(description = "ID de l'entreprise") @PathVariable UUID companyId) {
log.debug("Récupération des abonnements de l'entreprise {}", companyId);
List<Subscription> subscriptions = subscriptionService.findByCompanyId(companyId);
List<SubscriptionDto.Summary> dtos = subscriptions.stream()
.map(dtoMapper::toSummaryDto)
.collect(Collectors.toList());
return ResponseEntity.ok(dtos);
}
@GetMapping("/requiring-attention")
@PreAuthorize("hasRole('ADMIN')")
@Operation(summary = "Obtenir les abonnements nécessitant une attention")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "Abonnements nécessitant une attention"),
@ApiResponse(responseCode = "403", description = "Accès refusé")
})
public ResponseEntity<List<SubscriptionDto.Summary>> getSubscriptionsRequiringAttention() {
log.debug("Récupération des abonnements nécessitant une attention");
List<Subscription> subscriptions = subscriptionService.findSubscriptionsRequiringAttention();
List<SubscriptionDto.Summary> dtos = subscriptions.stream()
.map(dtoMapper::toSummaryDto)
.collect(Collectors.toList());
return ResponseEntity.ok(dtos);
}
@GetMapping("/trial/ending-soon")
@PreAuthorize("hasRole('ADMIN')")
@Operation(summary = "Obtenir les abonnements en essai qui se terminent bientôt")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "Essais se terminant bientôt"),
@ApiResponse(responseCode = "403", description = "Accès refusé")
})
public ResponseEntity<List<SubscriptionDto.Summary>> getTrialsEndingSoon(
@Parameter(description = "Nombre de jours d'avance") @RequestParam(defaultValue = "7") int daysAhead) {
log.debug("Récupération des essais se terminant dans {} jours", daysAhead);
List<Subscription> subscriptions = subscriptionService.findTrialsEndingSoon(daysAhead);
List<SubscriptionDto.Summary> dtos = subscriptions.stream()
.map(dtoMapper::toSummaryDto)
.collect(Collectors.toList());
return ResponseEntity.ok(dtos);
}
}

View File

@ -2,35 +2,101 @@ package com.dh7789dev.xpeditis.controller.api.v1;
import com.dh7789dev.xpeditis.AuthenticationService;
import com.dh7789dev.xpeditis.dto.request.AuthenticationRequest;
import com.dh7789dev.xpeditis.dto.response.AuthenticationResponse;
import com.dh7789dev.xpeditis.dto.request.GoogleAuthRequest;
import com.dh7789dev.xpeditis.dto.request.RegisterRequest;
import com.dh7789dev.xpeditis.dto.response.AuthenticationResponse;
import com.dh7789dev.xpeditis.dto.response.UserResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE;
@Slf4j
@RestController
@RequestMapping(value = "${apiPrefix}/api/v1/auth",
produces = APPLICATION_JSON_VALUE)
@RequestMapping(value = "${apiPrefix}/api/v1/auth", produces = APPLICATION_JSON_VALUE)
@RequiredArgsConstructor
@Tag(name = "Authentication", description = "Authentication and registration endpoints")
public class AuthenticationRestController {
private final AuthenticationService service;
private final AuthenticationService authenticationService;
@Operation(summary = "User login", description = "Authenticate user with email/username and password")
@ApiResponse(responseCode = "200", description = "Successfully authenticated")
@ApiResponse(responseCode = "401", description = "Invalid credentials")
@PostMapping("/login")
public ResponseEntity<AuthenticationResponse> authenticate(
@RequestBody @Valid AuthenticationRequest request) {
return ResponseEntity.ok(service.authenticate(request));
log.info("Authentication attempt for user: {}", request.getUsername());
AuthenticationResponse response = authenticationService.authenticate(request);
return ResponseEntity.ok(response);
}
@Operation(summary = "User registration", description = "Register a new user account")
@ApiResponse(responseCode = "201", description = "User successfully registered")
@ApiResponse(responseCode = "400", description = "Invalid registration data")
@ApiResponse(responseCode = "409", description = "User already exists")
@PostMapping("/register")
public ResponseEntity<AuthenticationResponse> register(
@RequestBody @Valid RegisterRequest request) {
return ResponseEntity.ok(service.register(request));
log.info("Registration attempt for email: {}", request.getEmail());
AuthenticationResponse response = authenticationService.register(request);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
@Operation(summary = "Google OAuth authentication", description = "Authenticate user with Google OAuth token")
@ApiResponse(responseCode = "200", description = "Successfully authenticated with Google")
@ApiResponse(responseCode = "401", description = "Invalid Google token")
@PostMapping("/google")
public ResponseEntity<AuthenticationResponse> authenticateWithGoogle(
@RequestBody @Valid GoogleAuthRequest request) {
log.info("Google OAuth authentication attempt");
AuthenticationResponse response = authenticationService.authenticateWithGoogle(request.getGoogleToken());
return ResponseEntity.ok(response);
}
@Operation(summary = "Refresh token", description = "Get a new access token using refresh token")
@ApiResponse(responseCode = "200", description = "Token successfully refreshed")
@ApiResponse(responseCode = "401", description = "Invalid refresh token")
@PostMapping("/refresh")
public ResponseEntity<AuthenticationResponse> refreshToken(
@RequestBody RefreshTokenRequest request) {
log.info("Token refresh attempt");
AuthenticationResponse response = authenticationService.refreshToken(request.getRefreshToken());
return ResponseEntity.ok(response);
}
@Operation(summary = "User logout", description = "Invalidate user session and tokens")
@ApiResponse(responseCode = "204", description = "Successfully logged out")
@PostMapping("/logout")
public ResponseEntity<Void> logout(HttpServletRequest request) {
String authHeader = request.getHeader("Authorization");
if (authHeader != null && authHeader.startsWith("Bearer ")) {
String token = authHeader.substring(7);
authenticationService.logout(token);
}
return ResponseEntity.noContent().build();
}
// Inner class for refresh token request
public static class RefreshTokenRequest {
@Valid
private String refreshToken;
public String getRefreshToken() {
return refreshToken;
}
public void setRefreshToken(String refreshToken) {
this.refreshToken = refreshToken;
}
}
}

View File

@ -0,0 +1,107 @@
package com.dh7789dev.xpeditis.controller.api.v1;
import com.dh7789dev.xpeditis.DevisCalculService;
import com.dh7789dev.xpeditis.dto.app.DemandeDevis;
import com.dh7789dev.xpeditis.dto.app.ReponseDevis;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import jakarta.validation.Valid;
@RestController
@RequestMapping("/api/v1/devis")
@RequiredArgsConstructor
@Validated
@Slf4j
@Tag(name = "Devis", description = "API pour le calcul automatisé de devis transport")
public class DevisRestController {
private final DevisCalculService devisCalculService;
@Operation(
summary = "Calculer les 3 offres de transport",
description = "Génère automatiquement 3 offres (Rapide, Standard, Économique) basées sur les grilles tarifaires"
)
@ApiResponses(value = {
@ApiResponse(
responseCode = "200",
description = "Devis calculé avec succès",
content = @Content(mediaType = "application/json", schema = @Schema(implementation = ReponseDevis.class))
),
@ApiResponse(
responseCode = "400",
description = "Données de la demande invalides"
),
@ApiResponse(
responseCode = "404",
description = "Aucune grille tarifaire applicable"
),
@ApiResponse(
responseCode = "500",
description = "Erreur interne du serveur"
)
})
@PostMapping("/calculer")
public ResponseEntity<ReponseDevis> calculerDevis(
@Parameter(description = "Demande de devis avec tous les détails du transport", required = true)
@Valid @RequestBody DemandeDevis demandeDevis) {
log.info("Demande de calcul de devis reçue pour le client: {}", demandeDevis.getNomClient());
try {
ReponseDevis reponseDevis = devisCalculService.calculerTroisOffres(demandeDevis);
log.info("Devis calculé avec succès - {} offres générées", reponseDevis.getOffres().size());
return ResponseEntity.ok(reponseDevis);
} catch (IllegalArgumentException e) {
log.warn("Données de demande invalides: {}", e.getMessage());
return ResponseEntity.badRequest().build();
} catch (IllegalStateException e) {
log.warn("Aucune grille tarifaire applicable: {}", e.getMessage());
return ResponseEntity.notFound().build();
} catch (Exception e) {
log.error("Erreur lors du calcul du devis", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
@Operation(
summary = "Valider une demande de devis",
description = "Vérifie que tous les champs obligatoires sont présents et valides"
)
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "Demande de devis valide"),
@ApiResponse(responseCode = "400", description = "Demande de devis invalide")
})
@PostMapping("/valider")
public ResponseEntity<String> validerDemandeDevis(
@Parameter(description = "Demande de devis à valider", required = true)
@Valid @RequestBody DemandeDevis demandeDevis) {
log.debug("Validation de la demande de devis");
try {
devisCalculService.validerDemandeDevis(demandeDevis);
return ResponseEntity.ok("Demande de devis valide");
} catch (IllegalArgumentException e) {
log.warn("Demande de devis invalide: {}", e.getMessage());
return ResponseEntity.badRequest().body(e.getMessage());
}
}
}

View File

@ -0,0 +1,363 @@
package com.dh7789dev.xpeditis.controller.api.v1;
import com.dh7789dev.xpeditis.dto.app.*;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import jakarta.validation.Valid;
import java.io.IOException;
@RestController
@RequestMapping("/api/v1/documents")
@RequiredArgsConstructor
@Slf4j
@Tag(name = "Documents", description = "Gestion des documents SSC avec validation")
@SecurityRequirement(name = "bearerAuth")
public class DocumentRestController {
// TODO: Inject required services
// private final DocumentValidationService documentValidationService;
// private final DocumentStorageService documentStorageService;
// private final ExportFolderPermissionService permissionService;
// private final NotificationService notificationService;
@Operation(
summary = "Télécharger un document",
description = "Télécharge le fichier document si autorisé",
responses = {
@ApiResponse(responseCode = "200", description = "Fichier téléchargé"),
@ApiResponse(responseCode = "403", description = "Accès refusé"),
@ApiResponse(responseCode = "404", description = "Document non trouvé")
}
)
@GetMapping("/{id}/download")
@PreAuthorize("hasRole('COMPANY_USER') or hasRole('ADMIN')")
public ResponseEntity<Resource> downloadDocument(
@Parameter(description = "ID du document")
@PathVariable Long id
) {
try {
// TODO: Implement download with permission check
// UserEntity currentUser = getCurrentUser();
// DocumentDossierEntity document = documentService.findById(id);
//
// if (!permissionService.canPerformAction(currentUser, document.getDossier(), FolderAction.DOWNLOAD_DOCUMENTS)) {
// return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
// }
//
// Resource resource = documentStorageService.loadAsResource(document);
log.info("Téléchargement document ID: {}", id);
// Placeholder response - would return actual file resource
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"document.pdf\"")
.body(null); // Would return actual Resource
} catch (Exception e) {
log.error("Erreur lors du téléchargement du document {}", id, e);
return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
}
}
@Operation(
summary = "Valider un document",
description = "Approuve ou refuse un document (admin SSC uniquement)",
responses = {
@ApiResponse(responseCode = "200", description = "Document validé"),
@ApiResponse(responseCode = "403", description = "Droits insuffisants"),
@ApiResponse(responseCode = "404", description = "Document non trouvé")
}
)
@PutMapping("/{id}/validate")
@PreAuthorize("hasRole('ADMIN_SSC') or hasRole('SUPER_ADMIN')")
public ResponseEntity<DocumentSummaryDto> validateDocument(
@Parameter(description = "ID du document")
@PathVariable Long id,
@Parameter(description = "Décision de validation")
@Valid @RequestBody ValidationRequest request
) {
try {
// TODO: Implement document validation
// UserEntity currentUser = getCurrentUser();
// DocumentSummaryDto validatedDocument = documentValidationService.processValidation(
// currentUser, id, request);
log.info("Validation document {} - décision: {}, commentaire: {}",
id, request.isApproved(), request.getComment());
// Placeholder response
return ResponseEntity.ok(DocumentSummaryDto.builder()
.id(id)
.statutVerification(request.isApproved() ?
StatutVerification.VALIDE : StatutVerification.REFUSE)
.build());
} catch (Exception e) {
log.error("Erreur lors de la validation du document {}", id, e);
return ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
}
}
@Operation(
summary = "Supprimer un document",
description = "Supprime un document si autorisé",
responses = {
@ApiResponse(responseCode = "204", description = "Document supprimé"),
@ApiResponse(responseCode = "403", description = "Accès refusé"),
@ApiResponse(responseCode = "404", description = "Document non trouvé")
}
)
@DeleteMapping("/{id}")
@PreAuthorize("hasRole('COMPANY_USER') or hasRole('ADMIN')")
public ResponseEntity<Void> deleteDocument(
@Parameter(description = "ID du document")
@PathVariable Long id
) {
try {
// TODO: Implement deletion with permission check
// UserEntity currentUser = getCurrentUser();
// DocumentDossierEntity document = documentService.findById(id);
//
// if (!permissionService.canPerformAction(currentUser, document.getDossier(), FolderAction.DELETE_DOCUMENTS)) {
// return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
// }
//
// documentValidationService.deleteDocument(currentUser, id);
log.info("Suppression document ID: {}", id);
return ResponseEntity.noContent().build();
} catch (Exception e) {
log.error("Erreur lors de la suppression du document {}", id, e);
return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
}
}
@Operation(
summary = "Uploader une nouvelle version",
description = "Remplace un document par une nouvelle version",
responses = {
@ApiResponse(responseCode = "201", description = "Nouvelle version uploadée"),
@ApiResponse(responseCode = "400", description = "Fichier invalide"),
@ApiResponse(responseCode = "403", description = "Accès refusé")
}
)
@PostMapping("/{id}/new-version")
@PreAuthorize("hasRole('COMPANY_USER') or hasRole('ADMIN')")
public ResponseEntity<DocumentSummaryDto> uploadNewVersion(
@Parameter(description = "ID du document original")
@PathVariable Long id,
@Parameter(description = "Nouveau fichier")
@RequestPart("file") MultipartFile file,
@Parameter(description = "Description des changements")
@RequestPart(value = "description", required = false) String description
) {
try {
// TODO: Implement version upload
// UserEntity currentUser = getCurrentUser();
// DocumentDossierEntity originalDocument = documentService.findById(id);
//
// if (!permissionService.canPerformAction(currentUser, originalDocument.getDossier(), FolderAction.UPLOAD_DOCUMENTS)) {
// return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
// }
//
// DocumentSummaryDto newVersion = documentValidationService.uploadNewVersion(
// currentUser, id, file, description);
log.info("Upload nouvelle version document {} - fichier: {}", id, file.getOriginalFilename());
// Placeholder response
return ResponseEntity.status(HttpStatus.CREATED)
.body(DocumentSummaryDto.builder()
.id(id + 1000L) // Simulated new version ID
.nomOriginal(file.getOriginalFilename())
.numeroVersion(2)
.statutVerification(StatutVerification.EN_ATTENTE)
.build());
} catch (Exception e) {
log.error("Erreur lors de l'upload de nouvelle version pour document {}", id, e);
return ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
}
}
@Operation(
summary = "Obtenir les détails d'un document",
description = "Récupère les informations complètes d'un document",
responses = {
@ApiResponse(responseCode = "200", description = "Détails du document"),
@ApiResponse(responseCode = "403", description = "Accès refusé"),
@ApiResponse(responseCode = "404", description = "Document non trouvé")
}
)
@GetMapping("/{id}")
@PreAuthorize("hasRole('COMPANY_USER') or hasRole('ADMIN')")
public ResponseEntity<DocumentDetailDto> getDocumentDetails(
@Parameter(description = "ID du document")
@PathVariable Long id
) {
try {
// TODO: Implement details retrieval with permissions
// UserEntity currentUser = getCurrentUser();
// DocumentDetailDto details = documentService.getDetailsWithPermissions(currentUser, id);
log.info("Consultation détails document ID: {}", id);
// Placeholder response
return ResponseEntity.ok(DocumentDetailDto.builder()
.id(id)
.statutVerification(StatutVerification.EN_ATTENTE)
.build());
} catch (Exception e) {
log.error("Erreur lors de la récupération des détails du document {}", id, e);
return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
}
}
@Operation(
summary = "Marquer un document comme expiré",
description = "Force l'expiration d'un document (admin uniquement)",
responses = {
@ApiResponse(responseCode = "200", description = "Document marqué expiré"),
@ApiResponse(responseCode = "403", description = "Droits insuffisants"),
@ApiResponse(responseCode = "404", description = "Document non trouvé")
}
)
@PutMapping("/{id}/expire")
@PreAuthorize("hasRole('ADMIN_SSC') or hasRole('SUPER_ADMIN')")
public ResponseEntity<Void> expireDocument(
@Parameter(description = "ID du document")
@PathVariable Long id,
@Parameter(description = "Raison de l'expiration")
@RequestBody ExpireDocumentRequest request
) {
try {
// TODO: Implement document expiration
// UserEntity currentUser = getCurrentUser();
// documentValidationService.expireDocument(currentUser, id, request.getReason());
log.info("Expiration document {} - raison: {}", id, request.getReason());
return ResponseEntity.ok().build();
} catch (Exception e) {
log.error("Erreur lors de l'expiration du document {}", id, e);
return ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
}
}
@Operation(
summary = "Obtenir l'historique de validation d'un document",
description = "Liste chronologique des validations d'un document",
responses = {
@ApiResponse(responseCode = "200", description = "Historique de validation"),
@ApiResponse(responseCode = "403", description = "Accès refusé"),
@ApiResponse(responseCode = "404", description = "Document non trouvé")
}
)
@GetMapping("/{id}/validation-history")
@PreAuthorize("hasRole('COMPANY_USER') or hasRole('ADMIN')")
public ResponseEntity<java.util.List<ValidationHistoryDto>> getValidationHistory(
@Parameter(description = "ID du document")
@PathVariable Long id
) {
try {
// TODO: Implement validation history retrieval
// UserEntity currentUser = getCurrentUser();
// List<ValidationHistoryDto> history = documentService.getValidationHistory(currentUser, id);
log.info("Consultation historique validation document ID: {}", id);
// Placeholder response
return ResponseEntity.ok(java.util.List.of());
} catch (Exception e) {
log.error("Erreur lors de la récupération de l'historique de validation du document {}", id, e);
return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
}
}
// TODO: Add getCurrentUser() method to get authenticated user
// private UserEntity getCurrentUser() {
// // Implementation to get current authenticated user
// return null;
// }
}
// ========== REQUEST DTOs ==========
@lombok.Data
class ValidationRequest {
private boolean approved;
private String comment;
private String correctionsDemandees;
}
@lombok.Data
class ExpireDocumentRequest {
private String reason;
}
// ========== RESPONSE DTOs ==========
@lombok.Data
@lombok.Builder
@lombok.NoArgsConstructor
@lombok.AllArgsConstructor
class DocumentDetailDto {
private Long id;
private String nomOriginal;
private String typeDocumentNom;
private Long tailleOctets;
private String typeMime;
private Integer numeroVersion;
private StatutVerification statutVerification;
private String commentaireVerification;
private String correctionsDemandees;
private String description;
private java.time.LocalDate dateValidite;
private boolean isExpired;
private String uploadePar;
private java.time.LocalDateTime dateUpload;
private String verifiePar;
private java.time.LocalDateTime dateVerification;
private boolean canDownload;
private boolean canDelete;
private boolean canValidate;
private boolean canUploadNewVersion;
}
@lombok.Data
@lombok.Builder
@lombok.NoArgsConstructor
@lombok.AllArgsConstructor
class ValidationHistoryDto {
private Long id;
private StatutVerification ancienStatut;
private StatutVerification nouveauStatut;
private String commentaire;
private String effectuePar;
private java.time.LocalDateTime dateValidation;
private String reason;
}

View File

@ -0,0 +1,274 @@
package com.dh7789dev.xpeditis.controller.api.v1;
import com.dh7789dev.xpeditis.dto.app.*;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import jakarta.validation.Valid;
import java.util.List;
import java.util.Map;
import java.util.Set;
@RestController
@RequestMapping("/api/v1/export-folders")
@RequiredArgsConstructor
@Slf4j
@Tag(name = "Export Folders", description = "Gestion des dossiers d'export SSC")
@SecurityRequirement(name = "bearerAuth")
public class ExportFolderRestController {
// TODO: Inject permission service when modules are properly connected
@Operation(
summary = "Rechercher des dossiers d'export",
description = "Recherche paginée avec filtres selon permissions utilisateur"
)
@GetMapping
@PreAuthorize("hasRole('COMPANY_USER') or hasRole('ADMIN')")
public ResponseEntity<Page<ExportFolderDto>> searchFolders(
@Parameter(description = "Critères de recherche")
@RequestParam Map<String, String> params,
@Parameter(description = "Numéro de page (0-indexed)")
@RequestParam(defaultValue = "0") int page,
@Parameter(description = "Taille de page")
@RequestParam(defaultValue = "20") int size,
@Parameter(description = "Champ de tri")
@RequestParam(defaultValue = "dateCreation") String sortBy,
@Parameter(description = "Direction de tri")
@RequestParam(defaultValue = "DESC") String sortDir
) {
try {
Pageable pageable = PageRequest.of(
page, size,
Sort.Direction.fromString(sortDir),
sortBy
);
log.info("Recherche dossiers - page: {}, size: {}, filtres: {}", page, size, params);
// Placeholder response
return ResponseEntity.ok(Page.empty());
} catch (Exception e) {
log.error("Erreur lors de la recherche de dossiers", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
@Operation(
summary = "Créer un dossier d'export depuis un devis",
description = "Crée un nouveau dossier d'export à partir d'un devis accepté"
)
@PostMapping
@PreAuthorize("hasRole('COMPANY_USER') or hasRole('ADMIN')")
public ResponseEntity<ExportFolderDto> createFromQuote(
@Parameter(description = "Données pour création du dossier")
@Valid @RequestBody CreateFolderRequest request
) {
try {
log.info("Création dossier depuis devis ID: {}", request.getQuoteId());
// Placeholder response
return ResponseEntity.status(HttpStatus.CREATED)
.body(ExportFolderDto.builder().build());
} catch (Exception e) {
log.error("Erreur lors de la création du dossier", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
@Operation(
summary = "Obtenir un dossier par ID",
description = "Récupère les détails complets d'un dossier selon permissions"
)
@GetMapping("/{id}")
@PreAuthorize("hasRole('COMPANY_USER') or hasRole('ADMIN')")
public ResponseEntity<ExportFolderDto> getFolder(
@Parameter(description = "ID du dossier")
@PathVariable Long id
) {
try {
log.info("Consultation dossier ID: {}", id);
// Placeholder response
return ResponseEntity.ok(ExportFolderDto.builder().id(id).build());
} catch (Exception e) {
log.error("Erreur lors de la récupération du dossier {}", id, e);
return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
}
}
@Operation(
summary = "Mettre à jour le statut d'un dossier",
description = "Change le statut d'un dossier selon le workflow"
)
@PutMapping("/{id}/status")
@PreAuthorize("hasRole('ADMIN_SSC') or hasRole('SUPER_ADMIN')")
public ResponseEntity<Void> updateStatus(
@Parameter(description = "ID du dossier")
@PathVariable Long id,
@Parameter(description = "Nouveau statut")
@Valid @RequestBody StatusUpdateRequest request
) {
try {
log.info("Mise à jour statut dossier {} vers {}", id, request.getNewStatus());
return ResponseEntity.ok().build();
} catch (Exception e) {
log.error("Erreur lors de la mise à jour du statut du dossier {}", id, e);
return ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
}
}
@Operation(
summary = "Uploader un document",
description = "Ajoute un nouveau document au dossier"
)
@PostMapping("/{id}/documents")
@PreAuthorize("hasRole('COMPANY_USER') or hasRole('ADMIN')")
public ResponseEntity<DocumentSummaryDto> uploadDocument(
@Parameter(description = "ID du dossier")
@PathVariable Long id,
@Parameter(description = "Fichier à uploader")
@RequestPart("file") MultipartFile file,
@Parameter(description = "ID du type de document")
@RequestPart("typeDocumentId") Long typeDocumentId,
@Parameter(description = "Description optionnelle")
@RequestPart(value = "description", required = false) String description
) {
try {
log.info("Upload document pour dossier {} - fichier: {}, type: {}",
id, file.getOriginalFilename(), typeDocumentId);
// Placeholder response
return ResponseEntity.status(HttpStatus.CREATED)
.body(DocumentSummaryDto.builder().build());
} catch (Exception e) {
log.error("Erreur lors de l'upload de document pour dossier {}", id, e);
return ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
}
}
@Operation(
summary = "Obtenir les documents d'un dossier",
description = "Liste tous les documents avec statuts de validation"
)
@GetMapping("/{id}/documents")
@PreAuthorize("hasRole('COMPANY_USER') or hasRole('ADMIN')")
public ResponseEntity<List<DocumentSummaryDto>> getDocuments(
@Parameter(description = "ID du dossier")
@PathVariable Long id
) {
try {
log.info("Consultation documents dossier ID: {}", id);
// Placeholder response
return ResponseEntity.ok(List.of());
} catch (Exception e) {
log.error("Erreur lors de la récupération des documents du dossier {}", id, e);
return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
}
}
@Operation(
summary = "Obtenir l'historique d'un dossier",
description = "Liste chronologique des actions sur le dossier"
)
@GetMapping("/{id}/history")
@PreAuthorize("hasRole('COMPANY_USER') or hasRole('ADMIN')")
public ResponseEntity<List<HistoryEntryDto>> getHistory(
@Parameter(description = "ID du dossier")
@PathVariable Long id,
@Parameter(description = "Nombre d'entrées max")
@RequestParam(defaultValue = "50") int limit
) {
try {
log.info("Consultation historique dossier ID: {}, limit: {}", id, limit);
// Placeholder response
return ResponseEntity.ok(List.of());
} catch (Exception e) {
log.error("Erreur lors de la récupération de l'historique du dossier {}", id, e);
return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
}
}
@Operation(
summary = "Obtenir les actions autorisées",
description = "Liste les actions que l'utilisateur peut effectuer sur ce dossier"
)
@GetMapping("/{id}/permissions")
@PreAuthorize("hasRole('COMPANY_USER') or hasRole('ADMIN')")
public ResponseEntity<Set<FolderAction>> getAuthorizedActions(
@Parameter(description = "ID du dossier")
@PathVariable Long id
) {
try {
log.info("Consultation permissions dossier ID: {}", id);
// Placeholder response
return ResponseEntity.ok(Set.of(FolderAction.VIEW_BASIC_INFO));
} catch (Exception e) {
log.error("Erreur lors de la récupération des permissions du dossier {}", id, e);
return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
}
}
}
// ========== REQUEST DTOs ==========
@lombok.Data
class CreateFolderRequest {
private Long quoteId;
private String commentairesClient;
}
@lombok.Data
class StatusUpdateRequest {
private DossierStatus newStatus;
private String comment;
}
@lombok.Data
class AssignFolderRequest {
private Long adminId;
private String comment;
}
@lombok.Data
class UpdateTransportRequest {
private String referenceBooking;
private String numeroBl;
private String numeroConteneur;
private String notesInternes;
}

View File

@ -0,0 +1,358 @@
package com.dh7789dev.xpeditis.controller.api.v1;
import com.dh7789dev.xpeditis.GrilleTarifaireService;
import com.dh7789dev.xpeditis.dto.app.GrilleTarifaire;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import jakarta.validation.Valid;
import java.io.IOException;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/v1/grilles-tarifaires")
@RequiredArgsConstructor
@Validated
@Slf4j
@Tag(name = "Grilles Tarifaires", description = "API pour la gestion et l'import des grilles tarifaires")
public class GrilleTarifaireRestController {
private final GrilleTarifaireService grilleTarifaireService;
@Operation(
summary = "Importer des grilles tarifaires depuis un fichier CSV",
description = "Import en masse de grilles tarifaires au format CSV avec validation des données"
)
@ApiResponses(value = {
@ApiResponse(
responseCode = "200",
description = "Import réussi avec détails des grilles importées"
),
@ApiResponse(
responseCode = "400",
description = "Fichier invalide ou erreurs de validation"
),
@ApiResponse(
responseCode = "415",
description = "Format de fichier non supporté"
)
})
@PostMapping("/import/csv")
public ResponseEntity<?> importerDepuisCsv(
@Parameter(description = "Fichier CSV contenant les grilles tarifaires", required = true)
@RequestParam("file") MultipartFile file,
@Parameter(description = "Mode d'import: REPLACE (remplace tout) ou MERGE (fusionne)")
@RequestParam(defaultValue = "MERGE") String mode) {
log.info("Début d'import CSV - Fichier: {}, Taille: {} bytes, Mode: {}",
file.getOriginalFilename(), file.getSize(), mode);
try {
if (file.isEmpty()) {
return ResponseEntity.badRequest()
.body(Map.of("erreur", "Le fichier ne peut pas être vide"));
}
if (!file.getOriginalFilename().toLowerCase().endsWith(".csv")) {
return ResponseEntity.status(HttpStatus.UNSUPPORTED_MEDIA_TYPE)
.body(Map.of("erreur", "Seuls les fichiers CSV sont supportés"));
}
List<GrilleTarifaire> grillesImportees = grilleTarifaireService.importerDepuisCsv(file, mode);
log.info("Import CSV terminé avec succès - {} grilles importées", grillesImportees.size());
return ResponseEntity.ok(Map.of(
"message", "Import réussi",
"nombreGrillesImportees", grillesImportees.size(),
"grilles", grillesImportees
));
} catch (IOException e) {
log.error("Erreur lors de la lecture du fichier CSV", e);
return ResponseEntity.badRequest()
.body(Map.of("erreur", "Erreur lors de la lecture du fichier: " + e.getMessage()));
} catch (IllegalArgumentException e) {
log.warn("Données CSV invalides: {}", e.getMessage());
return ResponseEntity.badRequest()
.body(Map.of("erreur", "Données invalides: " + e.getMessage()));
} catch (Exception e) {
log.error("Erreur lors de l'import CSV", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("erreur", "Erreur interne lors de l'import"));
}
}
@Operation(
summary = "Importer des grilles tarifaires depuis un fichier Excel",
description = "Import en masse de grilles tarifaires au format Excel (.xlsx) avec validation"
)
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "Import réussi"),
@ApiResponse(responseCode = "400", description = "Fichier invalide"),
@ApiResponse(responseCode = "415", description = "Format de fichier non supporté")
})
@PostMapping("/import/excel")
public ResponseEntity<?> importerDepuisExcel(
@Parameter(description = "Fichier Excel (.xlsx) contenant les grilles tarifaires", required = true)
@RequestParam("file") MultipartFile file,
@Parameter(description = "Nom de la feuille à importer (optionnel)")
@RequestParam(required = false) String sheetName,
@Parameter(description = "Mode d'import: REPLACE ou MERGE")
@RequestParam(defaultValue = "MERGE") String mode) {
log.info("Début d'import Excel - Fichier: {}, Feuille: {}, Mode: {}",
file.getOriginalFilename(), sheetName, mode);
try {
if (file.isEmpty()) {
return ResponseEntity.badRequest()
.body(Map.of("erreur", "Le fichier ne peut pas être vide"));
}
String filename = file.getOriginalFilename().toLowerCase();
if (!filename.endsWith(".xlsx") && !filename.endsWith(".xls")) {
return ResponseEntity.status(HttpStatus.UNSUPPORTED_MEDIA_TYPE)
.body(Map.of("erreur", "Seuls les fichiers Excel (.xlsx, .xls) sont supportés"));
}
List<GrilleTarifaire> grillesImportees = grilleTarifaireService.importerDepuisExcel(file, sheetName, mode);
log.info("Import Excel terminé avec succès - {} grilles importées", grillesImportees.size());
return ResponseEntity.ok(Map.of(
"message", "Import réussi",
"nombreGrillesImportees", grillesImportees.size(),
"grilles", grillesImportees
));
} catch (IOException e) {
log.error("Erreur lors de la lecture du fichier Excel", e);
return ResponseEntity.badRequest()
.body(Map.of("erreur", "Erreur lors de la lecture du fichier: " + e.getMessage()));
} catch (Exception e) {
log.error("Erreur lors de l'import Excel", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("erreur", "Erreur interne lors de l'import"));
}
}
@Operation(
summary = "Importer grilles tarifaires depuis JSON",
description = "Import de grilles tarifaires au format JSON avec validation complète"
)
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "Import réussi"),
@ApiResponse(responseCode = "400", description = "JSON invalide ou erreurs de validation")
})
@PostMapping("/import/json")
public ResponseEntity<?> importerDepuisJson(
@Parameter(description = "Liste des grilles tarifaires au format JSON", required = true)
@Valid @RequestBody List<GrilleTarifaire> grilles,
@Parameter(description = "Mode d'import: REPLACE ou MERGE")
@RequestParam(defaultValue = "MERGE") String mode) {
log.info("Début d'import JSON - {} grilles à traiter, Mode: {}", grilles.size(), mode);
try {
List<GrilleTarifaire> grillesImportees = grilleTarifaireService.importerDepuisJson(grilles, mode);
log.info("Import JSON terminé avec succès - {} grilles importées", grillesImportees.size());
return ResponseEntity.ok(Map.of(
"message", "Import réussi",
"nombreGrillesImportees", grillesImportees.size(),
"grilles", grillesImportees
));
} catch (IllegalArgumentException e) {
log.warn("Données JSON invalides: {}", e.getMessage());
return ResponseEntity.badRequest()
.body(Map.of("erreur", "Données invalides: " + e.getMessage()));
} catch (Exception e) {
log.error("Erreur lors de l'import JSON", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("erreur", "Erreur interne lors de l'import"));
}
}
@Operation(
summary = "Créer ou mettre à jour une grille tarifaire",
description = "Crée une nouvelle grille ou met à jour une grille existante"
)
@ApiResponses(value = {
@ApiResponse(responseCode = "201", description = "Grille créée avec succès"),
@ApiResponse(responseCode = "200", description = "Grille mise à jour avec succès"),
@ApiResponse(responseCode = "400", description = "Données invalides")
})
@PostMapping
public ResponseEntity<?> creerOuMettreAJourGrille(
@Parameter(description = "Grille tarifaire à créer ou mettre à jour", required = true)
@Valid @RequestBody GrilleTarifaire grille) {
log.info("Création/mise à jour grille tarifaire - Transporteur: {}, Route: {} -> {}",
grille.getTransporteur(), grille.getOriginePays(), grille.getDestinationPays());
try {
boolean isNew = (grille.getId() == null);
GrilleTarifaire grilleSauvegardee = grilleTarifaireService.sauvegarderGrille(grille);
HttpStatus status = isNew ? HttpStatus.CREATED : HttpStatus.OK;
String message = isNew ? "Grille créée avec succès" : "Grille mise à jour avec succès";
log.info("{} - ID: {}", message, grilleSauvegardee.getId());
return ResponseEntity.status(status).body(Map.of(
"message", message,
"grille", grilleSauvegardee
));
} catch (IllegalArgumentException e) {
log.warn("Données de grille invalides: {}", e.getMessage());
return ResponseEntity.badRequest()
.body(Map.of("erreur", "Données invalides: " + e.getMessage()));
} catch (Exception e) {
log.error("Erreur lors de la sauvegarde de la grille", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("erreur", "Erreur interne lors de la sauvegarde"));
}
}
@Operation(
summary = "Lister toutes les grilles tarifaires",
description = "Récupère la liste complète des grilles tarifaires avec pagination"
)
@GetMapping
public ResponseEntity<?> listerGrilles(
@Parameter(description = "Numéro de page (0-based)")
@RequestParam(defaultValue = "0") int page,
@Parameter(description = "Taille de la page")
@RequestParam(defaultValue = "20") int size,
@Parameter(description = "Filtrer par transporteur")
@RequestParam(required = false) String transporteur,
@Parameter(description = "Filtrer par pays d'origine")
@RequestParam(required = false) String paysOrigine,
@Parameter(description = "Filtrer par pays de destination")
@RequestParam(required = false) String paysDestination) {
try {
List<GrilleTarifaire> grilles = grilleTarifaireService.listerGrilles(
page, size, transporteur, paysOrigine, paysDestination);
return ResponseEntity.ok(Map.of(
"grilles", grilles,
"page", page,
"size", size,
"total", grilles.size()
));
} catch (Exception e) {
log.error("Erreur lors de la récupération des grilles", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("erreur", "Erreur lors de la récupération des grilles"));
}
}
@Operation(
summary = "Récupérer une grille tarifaire par ID",
description = "Récupère le détail complet d'une grille tarifaire"
)
@GetMapping("/{id}")
public ResponseEntity<?> obtenirGrilleParId(
@Parameter(description = "Identifiant de la grille tarifaire", required = true)
@PathVariable Long id) {
try {
GrilleTarifaire grille = grilleTarifaireService.trouverParId(id);
if (grille == null) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(grille);
} catch (Exception e) {
log.error("Erreur lors de la récupération de la grille ID: {}", id, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("erreur", "Erreur lors de la récupération de la grille"));
}
}
@Operation(
summary = "Supprimer une grille tarifaire",
description = "Supprime définitivement une grille tarifaire"
)
@DeleteMapping("/{id}")
public ResponseEntity<?> supprimerGrille(
@Parameter(description = "Identifiant de la grille tarifaire à supprimer", required = true)
@PathVariable Long id) {
try {
grilleTarifaireService.supprimerGrille(id);
log.info("Grille tarifaire supprimée - ID: {}", id);
return ResponseEntity.ok(Map.of(
"message", "Grille supprimée avec succès",
"id", id
));
} catch (Exception e) {
log.error("Erreur lors de la suppression de la grille ID: {}", id, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("erreur", "Erreur lors de la suppression de la grille"));
}
}
@Operation(
summary = "Valider la structure d'un fichier d'import",
description = "Valide la structure et les données d'un fichier avant import effectif"
)
@PostMapping("/validate")
public ResponseEntity<?> validerFichier(
@Parameter(description = "Fichier à valider (CSV ou Excel)", required = true)
@RequestParam("file") MultipartFile file) {
log.info("Validation fichier - Nom: {}, Taille: {} bytes",
file.getOriginalFilename(), file.getSize());
try {
if (file.isEmpty()) {
return ResponseEntity.badRequest()
.body(Map.of("erreur", "Le fichier ne peut pas être vide"));
}
Map<String, Object> resultatsValidation = grilleTarifaireService.validerFichier(file);
return ResponseEntity.ok(resultatsValidation);
} catch (IOException e) {
log.error("Erreur lors de la lecture du fichier", e);
return ResponseEntity.badRequest()
.body(Map.of("erreur", "Erreur lors de la lecture du fichier: " + e.getMessage()));
} catch (Exception e) {
log.error("Erreur lors de la validation", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("erreur", "Erreur lors de la validation"));
}
}
}

View File

@ -0,0 +1,103 @@
package com.dh7789dev.xpeditis.controller.api.v1;
import com.dh7789dev.xpeditis.UserService;
import com.dh7789dev.xpeditis.dto.app.UserAccount;
import com.dh7789dev.xpeditis.dto.request.UpdateProfileRequest;
import com.dh7789dev.xpeditis.dto.response.UserResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE;
@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping(value = "${apiPrefix}/api/v1/profile", produces = APPLICATION_JSON_VALUE)
@Tag(name = "Profile", description = "User profile management endpoints")
public class ProfileController {
private final UserService userService;
@Operation(summary = "Get user profile", description = "Retrieve the current user's profile information")
@ApiResponse(responseCode = "200", description = "Profile retrieved successfully")
@ApiResponse(responseCode = "401", description = "Unauthorized")
@GetMapping
public ResponseEntity<UserResponse> getProfile(Authentication authentication) {
log.info("Profile retrieval request for user: {}", authentication.getName());
UserAccount userAccount = userService.findByUsername(authentication.getName())
.orElseThrow(() -> new RuntimeException("User not found"));
UserResponse userResponse = mapToUserResponse(userAccount);
return ResponseEntity.ok(userResponse);
}
@Operation(summary = "Update profile", description = "Update the current user's profile information")
@ApiResponse(responseCode = "200", description = "Profile updated successfully")
@ApiResponse(responseCode = "400", description = "Invalid profile data")
@ApiResponse(responseCode = "401", description = "Unauthorized")
@PutMapping
public ResponseEntity<UserResponse> updateProfile(
@RequestBody @Valid UpdateProfileRequest request,
Authentication authentication) {
log.info("Profile update request for user: {}", authentication.getName());
// Get current user
UserAccount currentUser = userService.findByUsername(authentication.getName())
.orElseThrow(() -> new RuntimeException("User not found"));
// Update fields if provided
if (request.getFirstName() != null && !request.getFirstName().trim().isEmpty()) {
currentUser.setFirstName(request.getFirstName().trim());
}
if (request.getLastName() != null && !request.getLastName().trim().isEmpty()) {
currentUser.setLastName(request.getLastName().trim());
}
if (request.getPhoneNumber() != null && !request.getPhoneNumber().trim().isEmpty()) {
currentUser.setPhoneNumber(new com.dh7789dev.xpeditis.dto.valueobject.PhoneNumber(request.getPhoneNumber()));
}
if (request.getUsername() != null && !request.getUsername().trim().isEmpty()) {
// Check if username is available
if (!userService.existsByUsername(request.getUsername()) ||
request.getUsername().equals(currentUser.getUsername())) {
currentUser.setUsername(request.getUsername().trim());
} else {
throw new RuntimeException("Username already exists");
}
}
// Save updated user
UserAccount updatedUser = userService.updateProfile(currentUser);
UserResponse userResponse = mapToUserResponse(updatedUser);
return ResponseEntity.ok(userResponse);
}
// Helper method to convert UserAccount to UserResponse
private UserResponse mapToUserResponse(UserAccount userAccount) {
return UserResponse.builder()
.id(userAccount.getId())
.firstName(userAccount.getFirstName())
.lastName(userAccount.getLastName())
.email(userAccount.getEmail() != null ? userAccount.getEmail().getValue() : null)
.username(userAccount.getUsername())
.phoneNumber(userAccount.getPhoneNumber() != null ? userAccount.getPhoneNumber().getValue() : null)
.authProvider(userAccount.getAuthProvider())
.privacyPolicyAccepted(userAccount.isPrivacyPolicyAccepted())
.privacyPolicyAcceptedAt(userAccount.getPrivacyPolicyAcceptedAt())
.createdAt(userAccount.getCreatedAt())
.lastLoginAt(userAccount.getLastLoginAt())
.isActive(userAccount.isActive())
.role(userAccount.getRole() != null ? userAccount.getRole().name() : null)
.companyName(userAccount.getCompany() != null ? userAccount.getCompany().getName() : null)
.companyId(userAccount.getCompany() != null ? userAccount.getCompany().getId() : null)
.build();
}
}

View File

@ -1,38 +1,171 @@
package com.dh7789dev.xpeditis.controller.api.v1;
import com.dh7789dev.xpeditis.UserService;
import com.dh7789dev.xpeditis.dto.app.UserAccount;
import com.dh7789dev.xpeditis.dto.request.ChangePasswordRequest;
import com.dh7789dev.xpeditis.dto.request.UpdateProfileRequest;
import com.dh7789dev.xpeditis.dto.response.UserResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.*;
import java.security.Principal;
import java.util.List;
import java.util.UUID;
import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE;
@Slf4j
@RestController
@Validated
@RequestMapping(value = "${apiPrefix}/api/v1/users",
produces = APPLICATION_JSON_VALUE)
@RequiredArgsConstructor
@RequestMapping(value = "${apiPrefix}/api/v1/users", produces = APPLICATION_JSON_VALUE)
@Tag(name = "User Management", description = "User profile and management endpoints")
public class UserRestController {
private final UserService service;
private final UserService userService;
public UserRestController(UserService service) {
this.service = service;
@Operation(summary = "Get current user profile", description = "Retrieve the profile of the authenticated user")
@ApiResponse(responseCode = "200", description = "User profile retrieved successfully")
@GetMapping("/profile")
public ResponseEntity<UserResponse> getProfile(Authentication authentication) {
log.info("Profile request for user: {}", authentication.getName());
// Get user by username from authentication
UserAccount userAccount = userService.findByUsername(authentication.getName())
.orElseThrow(() -> new RuntimeException("User not found"));
// Convert to response DTO
UserResponse userResponse = mapToUserResponse(userAccount);
return ResponseEntity.ok(userResponse);
}
@Operation(summary = "Change password of the connected user")
@PatchMapping("/password")
@Operation(summary = "Update user profile", description = "Update profile information of the authenticated user")
@ApiResponse(responseCode = "200", description = "Profile updated successfully")
@ApiResponse(responseCode = "400", description = "Invalid profile data")
@PutMapping("/profile")
public ResponseEntity<UserResponse> updateProfile(
@RequestBody @Valid UpdateProfileRequest request,
Authentication authentication) {
log.info("Profile update request for user: {}", authentication.getName());
// Get current user
UserAccount currentUser = userService.findByUsername(authentication.getName())
.orElseThrow(() -> new RuntimeException("User not found"));
// Update fields
if (request.getFirstName() != null) {
currentUser.setFirstName(request.getFirstName());
}
if (request.getLastName() != null) {
currentUser.setLastName(request.getLastName());
}
if (request.getPhoneNumber() != null) {
currentUser.setPhoneNumber(new com.dh7789dev.xpeditis.dto.valueobject.PhoneNumber(request.getPhoneNumber()));
}
if (request.getUsername() != null && !userService.existsByUsername(request.getUsername())) {
currentUser.setUsername(request.getUsername());
}
// Save updated user
UserAccount updatedUser = userService.updateProfile(currentUser);
UserResponse userResponse = mapToUserResponse(updatedUser);
return ResponseEntity.ok(userResponse);
}
@Operation(summary = "Change password", description = "Change password of the authenticated user")
@ApiResponse(responseCode = "204", description = "Password changed successfully")
@ApiResponse(responseCode = "400", description = "Invalid password data")
@PutMapping("/password")
public ResponseEntity<Void> changePassword(
@RequestBody ChangePasswordRequest request,
@RequestBody @Valid ChangePasswordRequest request,
Principal connectedUser) {
service.changePassword(request, connectedUser);
return new ResponseEntity<>(HttpStatus.OK);
log.info("Password change request for user: {}", connectedUser.getName());
userService.changePassword(request, connectedUser);
return ResponseEntity.noContent().build();
}
@Operation(summary = "Delete user account", description = "Delete the authenticated user's account")
@ApiResponse(responseCode = "204", description = "Account deleted successfully")
@DeleteMapping("/account")
public ResponseEntity<Void> deleteAccount(Authentication authentication) {
log.info("Account deletion request for user: {}", authentication.getName());
UserAccount user = userService.findByUsername(authentication.getName())
.orElseThrow(() -> new RuntimeException("User not found"));
userService.deleteUser(user.getId());
return ResponseEntity.noContent().build();
}
@Operation(summary = "List users", description = "List all users (Admin only)")
@ApiResponse(responseCode = "200", description = "Users retrieved successfully")
@ApiResponse(responseCode = "403", description = "Insufficient permissions")
@GetMapping
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<Page<UserResponse>> listUsers(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(required = false) UUID companyId) {
log.info("User list request - page: {}, size: {}, companyId: {}", page, size, companyId);
List<UserAccount> userAccounts;
long totalElements;
if (companyId != null) {
userAccounts = userService.findUsersByCompany(companyId, page, size);
totalElements = userService.countUsersByCompany(companyId);
} else {
userAccounts = userService.findAllUsers(page, size);
totalElements = userService.countAllUsers();
}
// Convert to UserResponse DTOs
List<UserResponse> userResponses = userAccounts.stream()
.map(this::mapToUserResponse)
.collect(java.util.stream.Collectors.toList());
// Create Page object
Pageable pageable = PageRequest.of(page, size);
Page<UserResponse> users = new org.springframework.data.domain.PageImpl<>(
userResponses,
pageable,
totalElements);
return ResponseEntity.ok(users);
}
// Helper method to convert UserAccount to UserResponse
private UserResponse mapToUserResponse(UserAccount userAccount) {
return UserResponse.builder()
.id(userAccount.getId())
.firstName(userAccount.getFirstName())
.lastName(userAccount.getLastName())
.email(userAccount.getEmail() != null ? userAccount.getEmail().getValue() : null)
.username(userAccount.getUsername())
.phoneNumber(userAccount.getPhoneNumber() != null ? userAccount.getPhoneNumber().getValue() : null)
.authProvider(userAccount.getAuthProvider())
.privacyPolicyAccepted(userAccount.isPrivacyPolicyAccepted())
.privacyPolicyAcceptedAt(userAccount.getPrivacyPolicyAcceptedAt())
.createdAt(userAccount.getCreatedAt())
.lastLoginAt(userAccount.getLastLoginAt())
.isActive(userAccount.isActive())
.role(userAccount.getRole() != null ? userAccount.getRole().name() : null)
.companyName(userAccount.getCompany() != null ? userAccount.getCompany().getName() : null)
.companyId(userAccount.getCompany() != null ? userAccount.getCompany().getId() : null)
.build();
}
}

View File

@ -0,0 +1,247 @@
package com.dh7789dev.xpeditis.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonInclude;
import jakarta.validation.constraints.*;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
/**
* DTO pour les factures dans les réponses API
*/
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public class InvoiceDto {
private UUID id;
@NotBlank(message = "L'ID Stripe de la facture est obligatoire")
private String stripeInvoiceId;
@NotBlank(message = "Le numéro de facture est obligatoire")
@Size(max = 50, message = "Le numéro de facture ne peut pas dépasser 50 caractères")
private String invoiceNumber;
private UUID subscriptionId;
@NotNull(message = "Le statut est obligatoire")
private String status;
@NotNull(message = "Le montant dû est obligatoire")
@DecimalMin(value = "0.0", message = "Le montant dû doit être positif")
@Digits(integer = 8, fraction = 2, message = "Le montant dû doit avoir au maximum 8 chiffres avant la virgule et 2 après")
private BigDecimal amountDue;
@DecimalMin(value = "0.0", message = "Le montant payé doit être positif")
@Digits(integer = 8, fraction = 2, message = "Le montant payé doit avoir au maximum 8 chiffres avant la virgule et 2 après")
private BigDecimal amountPaid = BigDecimal.ZERO;
@NotBlank(message = "La devise est obligatoire")
@Size(min = 3, max = 3, message = "La devise doit faire exactement 3 caractères")
private String currency = "EUR";
@NotNull(message = "Le début de période de facturation est obligatoire")
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
private LocalDateTime billingPeriodStart;
@NotNull(message = "La fin de période de facturation est obligatoire")
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
private LocalDateTime billingPeriodEnd;
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
private LocalDateTime dueDate;
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
private LocalDateTime paidAt;
private String invoicePdfUrl;
private String hostedInvoiceUrl;
@Min(value = 0, message = "Le nombre de tentatives doit être positif")
private Integer attemptCount = 0;
// Relations
private List<InvoiceLineItemDto> lineItems;
// Métadonnées
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
private LocalDateTime createdAt;
// ===== PROPRIÉTÉS CALCULÉES =====
/**
* @return true si la facture est payée
*/
public Boolean isPaid() {
return "PAID".equals(status);
}
/**
* @return true si la facture est en attente de paiement
*/
public Boolean isPending() {
return "OPEN".equals(status);
}
/**
* @return true si la facture est en retard
*/
public Boolean isOverdue() {
return dueDate != null &&
LocalDateTime.now().isAfter(dueDate) &&
!isPaid();
}
/**
* @return le nombre de jours de retard (0 si pas en retard)
*/
public Long getDaysOverdue() {
if (!isOverdue()) return 0L;
return java.time.temporal.ChronoUnit.DAYS.between(dueDate, LocalDateTime.now());
}
/**
* @return le montant restant à payer
*/
public BigDecimal getRemainingAmount() {
if (amountPaid == null) return amountDue;
return amountDue.subtract(amountPaid);
}
/**
* @return true si la facture est partiellement payée
*/
public Boolean isPartiallyPaid() {
return amountPaid != null &&
amountPaid.compareTo(BigDecimal.ZERO) > 0 &&
amountPaid.compareTo(amountDue) < 0;
}
/**
* @return le pourcentage de paiement effectué
*/
public Double getPaymentPercentage() {
if (amountDue.compareTo(BigDecimal.ZERO) == 0) return 100.0;
if (amountPaid == null) return 0.0;
return amountPaid.divide(amountDue, 4, java.math.RoundingMode.HALF_UP)
.multiply(BigDecimal.valueOf(100))
.doubleValue();
}
/**
* @return la durée de la période de facturation en jours
*/
public Long getBillingPeriodDays() {
if (billingPeriodStart == null || billingPeriodEnd == null) return null;
return java.time.temporal.ChronoUnit.DAYS.between(billingPeriodStart, billingPeriodEnd);
}
/**
* @return true si cette facture nécessite une attention
*/
public Boolean requiresAttention() {
return isOverdue() ||
(attemptCount != null && attemptCount > 3) ||
"PAYMENT_FAILED".equals(status);
}
/**
* @return true si la facture inclut du prorata
*/
public Boolean hasProrationItems() {
return lineItems != null &&
lineItems.stream().anyMatch(item ->
item.getProrated() != null && item.getProrated());
}
// ===== SOUS-CLASSES POUR RESPONSES SPÉCIFIQUES =====
/**
* Version minimale pour les listes
*/
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public static class Summary {
private UUID id;
private String invoiceNumber;
private String status;
private BigDecimal amountDue;
private BigDecimal amountPaid;
private String currency;
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
private LocalDateTime dueDate;
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
private LocalDateTime createdAt;
private Boolean isOverdue;
private Boolean requiresAttention;
private Long daysOverdue;
private BigDecimal remainingAmount;
}
/**
* Version détaillée pour les vues individuelles
*/
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public static class Detailed extends InvoiceDto {
// Informations sur l'abonnement
private String subscriptionPlan;
private String companyName;
// Actions disponibles
private Boolean canDownloadPdf;
private Boolean canPayOnline;
private Boolean canSendReminder;
// Historique des paiements
private List<PaymentAttemptDto> paymentAttempts;
}
/**
* Version publique pour les clients (sans données sensibles)
*/
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public static class Customer {
private String invoiceNumber;
private String status;
private BigDecimal amountDue;
private BigDecimal amountPaid;
private String currency;
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
private LocalDateTime dueDate;
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
private LocalDateTime paidAt;
private String hostedInvoiceUrl;
private List<InvoiceLineItemDto> lineItems;
private BigDecimal remainingAmount;
private Boolean isOverdue;
private Long daysOverdue;
}
/**
* DTO pour les tentatives de paiement
*/
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public static class PaymentAttemptDto {
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
private LocalDateTime attemptDate;
private String status;
private String failureReason;
private BigDecimal attemptedAmount;
}
}

View File

@ -0,0 +1,187 @@
package com.dh7789dev.xpeditis.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonInclude;
import jakarta.validation.constraints.*;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.UUID;
/**
* DTO pour les lignes de facture dans les réponses API
*/
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public class InvoiceLineItemDto {
private UUID id;
private UUID invoiceId;
@Size(max = 1000, message = "La description ne peut pas dépasser 1000 caractères")
private String description;
@Min(value = 1, message = "La quantité doit être au minimum 1")
private Integer quantity = 1;
@DecimalMin(value = "0.0", message = "Le prix unitaire doit être positif")
@Digits(integer = 8, fraction = 2, message = "Le prix unitaire doit avoir au maximum 8 chiffres avant la virgule et 2 après")
private BigDecimal unitPrice;
@NotNull(message = "Le montant est obligatoire")
@DecimalMin(value = "0.0", message = "Le montant doit être positif")
@Digits(integer = 8, fraction = 2, message = "Le montant doit avoir au maximum 8 chiffres avant la virgule et 2 après")
private BigDecimal amount;
private String stripePriceId;
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
private LocalDateTime periodStart;
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
private LocalDateTime periodEnd;
private Boolean prorated = false;
// Métadonnées
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
private LocalDateTime createdAt;
// ===== PROPRIÉTÉS CALCULÉES =====
/**
* @return true si cette ligne représente un prorata
*/
public Boolean isProrationItem() {
return prorated != null && prorated;
}
/**
* @return la durée couverte par cette ligne en jours
*/
public Long getPeriodDays() {
if (periodStart == null || periodEnd == null) {
return null;
}
return java.time.temporal.ChronoUnit.DAYS.between(periodStart, periodEnd);
}
/**
* @return le prix unitaire calculé (amount / quantity)
*/
public BigDecimal getCalculatedUnitPrice() {
if (quantity == null || quantity == 0) {
return BigDecimal.ZERO;
}
return amount.divide(BigDecimal.valueOf(quantity), 2, java.math.RoundingMode.HALF_UP);
}
/**
* @return true si cette ligne couvre une période complète (mois ou année)
*/
public Boolean isFullPeriodItem() {
if (periodStart == null || periodEnd == null || isProrationItem()) {
return false;
}
Long days = getPeriodDays();
if (days == null) return false;
// Vérifier si c'est approximativement un mois (28-31 jours) ou une année (365-366 jours)
return (days >= 28 && days <= 31) || (days >= 365 && days <= 366);
}
/**
* @return le type de période (MONTH, YEAR, PRORATION, CUSTOM)
*/
public String getPeriodType() {
if (isProrationItem()) {
return "PRORATION";
}
Long days = getPeriodDays();
if (days == null) return "UNKNOWN";
if (days >= 28 && days <= 31) {
return "MONTH";
} else if (days >= 365 && days <= 366) {
return "YEAR";
} else {
return "CUSTOM";
}
}
/**
* @return le taux de prorata (pour les éléments proratés)
*/
public Double getProrationRate() {
if (!isProrationItem() || periodStart == null || periodEnd == null) {
return 1.0;
}
Long actualDays = getPeriodDays();
if (actualDays == null) return 1.0;
long expectedDays = getPeriodType().equals("YEAR") ? 365 : 30;
return actualDays.doubleValue() / expectedDays;
}
/**
* @return une description formatée avec les détails de période
*/
public String getFormattedDescription() {
StringBuilder sb = new StringBuilder();
if (description != null) {
sb.append(description);
}
if (periodStart != null && periodEnd != null) {
sb.append(" (")
.append(periodStart.toLocalDate())
.append(" - ")
.append(periodEnd.toLocalDate())
.append(")");
}
if (isProrationItem()) {
sb.append(" [Prorata]");
}
return sb.toString();
}
// ===== SOUS-CLASSES POUR RESPONSES SPÉCIFIQUES =====
/**
* Version minimale pour les résumés de facture
*/
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public static class Summary {
private String description;
private Integer quantity;
private BigDecimal amount;
private Boolean prorated;
private String periodType;
private String formattedDescription;
}
/**
* Version détaillée pour les analyses
*/
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public static class Detailed extends InvoiceLineItemDto {
// Analyse de la période
private Long periodDays;
private Double prorationRate;
private Boolean isFullPeriodItem;
// Comparaisons
private BigDecimal calculatedUnitPrice;
private String periodTypeDescription;
private String formattedDescription;
}
}

View File

@ -0,0 +1,120 @@
package com.dh7789dev.xpeditis.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonInclude;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.Set;
import java.util.UUID;
/**
* DTO pour les licences dans les réponses API
*/
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public class LicenseDto {
private UUID id;
@NotBlank(message = "La clé de licence est obligatoire")
private String licenseKey;
@NotNull(message = "Le type de licence est obligatoire")
private String type;
@NotNull(message = "Le statut de la licence est obligatoire")
private String status;
@NotNull(message = "La date de début est obligatoire")
@JsonFormat(pattern = "yyyy-MM-dd")
private LocalDate startDate;
@JsonFormat(pattern = "yyyy-MM-dd")
private LocalDate expirationDate;
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
private LocalDateTime issuedDate;
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
private LocalDateTime expiryDate;
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
private LocalDateTime gracePeriodEndDate;
private Integer maxUsers;
private Set<String> featuresEnabled;
private Boolean isActive;
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
private LocalDateTime createdAt;
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
private LocalDateTime updatedAt;
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
private LocalDateTime lastCheckedAt;
// ===== PROPRIÉTÉS CALCULÉES =====
/**
* @return true si la licence est expirée
*/
public Boolean isExpired() {
return expirationDate != null && expirationDate.isBefore(LocalDate.now());
}
/**
* @return true si la licence est active
*/
public Boolean isActive() {
return "ACTIVE".equals(status);
}
/**
* @return true si la licence est valide
*/
public Boolean isValid() {
return isActive() || isInGracePeriod();
}
/**
* @return true si la licence est en période de grâce
*/
public Boolean isInGracePeriod() {
return "GRACE_PERIOD".equals(status)
&& gracePeriodEndDate != null
&& LocalDateTime.now().isBefore(gracePeriodEndDate);
}
/**
* @return le nombre de jours jusqu'à l'expiration
*/
public Long getDaysUntilExpiration() {
return expirationDate != null ?
java.time.temporal.ChronoUnit.DAYS.between(LocalDate.now(), expirationDate) :
null;
}
/**
* @return true si la licence nécessite une attention
*/
public Boolean requiresAttention() {
return "SUSPENDED".equals(status)
|| (isInGracePeriod() && getDaysRemainingInGracePeriod() <= 1)
|| (getDaysUntilExpiration() != null && getDaysUntilExpiration() <= 7 && getDaysUntilExpiration() > 0);
}
/**
* @return le nombre de jours restants en période de grâce
*/
public Long getDaysRemainingInGracePeriod() {
if (gracePeriodEndDate == null || !isInGracePeriod()) {
return 0L;
}
return java.time.temporal.ChronoUnit.DAYS.between(LocalDateTime.now(), gracePeriodEndDate);
}
}

View File

@ -0,0 +1,256 @@
package com.dh7789dev.xpeditis.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonInclude;
import jakarta.validation.constraints.*;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.UUID;
/**
* DTO pour les méthodes de paiement dans les réponses API
*/
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public class PaymentMethodDto {
private UUID id;
@NotBlank(message = "L'ID Stripe de la méthode de paiement est obligatoire")
private String stripePaymentMethodId;
@NotNull(message = "Le type de méthode de paiement est obligatoire")
private String type;
private Boolean isDefault = false;
// Informations carte
@Size(max = 50, message = "La marque de la carte ne peut pas dépasser 50 caractères")
private String cardBrand;
@Size(min = 4, max = 4, message = "Les 4 derniers chiffres de la carte doivent faire exactement 4 caractères")
private String cardLast4;
@Min(value = 1, message = "Le mois d'expiration doit être entre 1 et 12")
@Max(value = 12, message = "Le mois d'expiration doit être entre 1 et 12")
private Integer cardExpMonth;
@Min(value = 2020, message = "L'année d'expiration doit être valide")
private Integer cardExpYear;
// Informations banque
@Size(max = 100, message = "Le nom de la banque ne peut pas dépasser 100 caractères")
private String bankName;
@NotNull(message = "L'ID de l'entreprise est obligatoire")
private UUID companyId;
// Métadonnées
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
private LocalDateTime createdAt;
// ===== PROPRIÉTÉS CALCULÉES =====
/**
* @return true si c'est une carte de crédit/débit
*/
public Boolean isCard() {
return "CARD".equals(type);
}
/**
* @return true si c'est un prélèvement bancaire SEPA
*/
public Boolean isSepaDebit() {
return "SEPA_DEBIT".equals(type);
}
/**
* @return true si cette méthode de paiement est la méthode par défaut
*/
public Boolean isDefaultPaymentMethod() {
return isDefault != null && isDefault;
}
/**
* @return true si la carte expire bientôt (dans les 2 prochains mois)
*/
public Boolean isCardExpiringSoon() {
if (!isCard() || cardExpMonth == null || cardExpYear == null) {
return false;
}
LocalDateTime now = LocalDateTime.now();
LocalDateTime expirationDate = LocalDateTime.of(cardExpYear, cardExpMonth, 1, 0, 0)
.plusMonths(1) // Le dernier jour du mois d'expiration
.minusDays(1);
LocalDateTime twoMonthsFromNow = now.plusMonths(2);
return expirationDate.isBefore(twoMonthsFromNow);
}
/**
* @return true si la carte est expirée
*/
public Boolean isCardExpired() {
if (!isCard() || cardExpMonth == null || cardExpYear == null) {
return false;
}
LocalDateTime now = LocalDateTime.now();
LocalDateTime expirationDate = LocalDateTime.of(cardExpYear, cardExpMonth, 1, 0, 0)
.plusMonths(1)
.minusDays(1);
return expirationDate.isBefore(now);
}
/**
* @return le nombre de jours jusqu'à l'expiration (négatif si déjà expirée)
*/
public Long getDaysUntilExpiration() {
if (!isCard() || cardExpMonth == null || cardExpYear == null) {
return null;
}
LocalDateTime now = LocalDateTime.now();
LocalDateTime expirationDate = LocalDateTime.of(cardExpYear, cardExpMonth, 1, 0, 0)
.plusMonths(1)
.minusDays(1);
return java.time.temporal.ChronoUnit.DAYS.between(now, expirationDate);
}
/**
* @return une description formatée de la méthode de paiement
*/
public String getDisplayName() {
if (isCard()) {
String brand = cardBrand != null ? cardBrand.toUpperCase() : "CARD";
String last4 = cardLast4 != null ? cardLast4 : "****";
return brand + " •••• " + last4;
} else if (isSepaDebit()) {
String bank = bankName != null ? bankName : "Bank";
return "SEPA - " + bank;
} else {
return type;
}
}
/**
* @return les détails d'expiration formatés (MM/YY)
*/
public String getFormattedExpiration() {
if (!isCard() || cardExpMonth == null || cardExpYear == null) {
return null;
}
return String.format("%02d/%02d", cardExpMonth, cardExpYear % 100);
}
/**
* @return true si cette méthode de paiement peut être utilisée pour des paiements récurrents
*/
public Boolean supportsRecurringPayments() {
return "CARD".equals(type) || "SEPA_DEBIT".equals(type);
}
/**
* @return true si cette méthode de paiement nécessite une attention
*/
public Boolean requiresAttention() {
return isCardExpired() || isCardExpiringSoon();
}
/**
* @return l'icône à afficher pour cette méthode de paiement
*/
public String getIconName() {
if (isCard() && cardBrand != null) {
return switch (cardBrand.toLowerCase()) {
case "visa" -> "visa";
case "mastercard" -> "mastercard";
case "amex", "american_express" -> "amex";
case "discover" -> "discover";
case "diners", "diners_club" -> "diners";
case "jcb" -> "jcb";
case "unionpay" -> "unionpay";
default -> "card";
};
} else if (isSepaDebit()) {
return "bank";
} else {
return switch (type) {
case "BANCONTACT" -> "bancontact";
case "GIROPAY" -> "giropay";
case "IDEAL" -> "ideal";
default -> "payment";
};
}
}
// ===== SOUS-CLASSES POUR RESPONSES SPÉCIFIQUES =====
/**
* Version publique pour les clients (sans données sensibles)
*/
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public static class Public {
private UUID id;
private String type;
private Boolean isDefault;
private String cardBrand;
private String cardLast4;
private String formattedExpiration;
private String bankName;
private String displayName;
private String iconName;
private Boolean requiresAttention;
private Boolean isCardExpiringSoon;
private Boolean supportsRecurringPayments;
}
/**
* Version administrative complète
*/
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public static class Admin extends PaymentMethodDto {
// Statistiques d'utilisation
private Long totalTransactions;
private Long successfulTransactions;
private Long failedTransactions;
private Double successRate;
// Dernière activité
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
private LocalDateTime lastUsed;
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
private LocalDateTime lastSyncWithStripe;
// Alertes
private Boolean needsAttention;
private String attentionReason;
}
/**
* Version minimal pour les listes
*/
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public static class Summary {
private UUID id;
private String type;
private Boolean isDefault;
private String displayName;
private String iconName;
private Boolean requiresAttention;
private Long daysUntilExpiration;
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
private LocalDateTime createdAt;
}
}

View File

@ -0,0 +1,194 @@
package com.dh7789dev.xpeditis.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonInclude;
import jakarta.validation.constraints.*;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
/**
* DTO pour les plans d'abonnement dans les réponses API
*/
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public class PlanDto {
private UUID id;
@NotBlank(message = "Le nom du plan est obligatoire")
@Size(max = 100, message = "Le nom du plan ne peut pas dépasser 100 caractères")
private String name;
@NotBlank(message = "Le type du plan est obligatoire")
@Size(max = 50, message = "Le type du plan ne peut pas dépasser 50 caractères")
private String type;
private String stripePriceIdMonthly;
private String stripePriceIdYearly;
@DecimalMin(value = "0.0", message = "Le prix mensuel doit être positif")
@Digits(integer = 8, fraction = 2, message = "Le prix mensuel doit avoir au maximum 8 chiffres avant la virgule et 2 après")
private BigDecimal monthlyPrice;
@DecimalMin(value = "0.0", message = "Le prix annuel doit être positif")
@Digits(integer = 8, fraction = 2, message = "Le prix annuel doit avoir au maximum 8 chiffres avant la virgule et 2 après")
private BigDecimal yearlyPrice;
@NotNull(message = "Le nombre maximum d'utilisateurs est obligatoire")
@Min(value = -1, message = "Le nombre maximum d'utilisateurs doit être -1 (illimité) ou positif")
private Integer maxUsers;
private Set<String> features;
@Min(value = 0, message = "La durée d'essai doit être positive")
private Integer trialDurationDays = 14;
private Boolean isActive = true;
@Min(value = 0, message = "L'ordre d'affichage doit être positif")
private Integer displayOrder;
private Map<String, Object> metadata;
// Métadonnées
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
private LocalDateTime createdAt;
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
private LocalDateTime updatedAt;
// ===== PROPRIÉTÉS CALCULÉES =====
/**
* @return true si le plan est disponible pour souscription
*/
public Boolean isAvailableForSubscription() {
return isActive != null && isActive;
}
/**
* @return true si le plan supporte un nombre illimité d'utilisateurs
*/
public Boolean hasUnlimitedUsers() {
return maxUsers != null && maxUsers == -1;
}
/**
* @return le nombre de fonctionnalités incluses
*/
public Integer getFeatureCount() {
return features != null ? features.size() : 0;
}
/**
* @return l'économie annuelle (différence entre 12*mensuel et annuel)
*/
public BigDecimal getYearlySavings() {
if (monthlyPrice == null || yearlyPrice == null) return null;
BigDecimal yearlyFromMonthly = monthlyPrice.multiply(BigDecimal.valueOf(12));
return yearlyFromMonthly.subtract(yearlyPrice);
}
/**
* @return le pourcentage d'économie annuelle
*/
public Double getYearlySavingsPercentage() {
BigDecimal savings = getYearlySavings();
if (savings == null || monthlyPrice == null || monthlyPrice.compareTo(BigDecimal.ZERO) == 0) {
return null;
}
BigDecimal yearlyFromMonthly = monthlyPrice.multiply(BigDecimal.valueOf(12));
return savings.divide(yearlyFromMonthly, 4, java.math.RoundingMode.HALF_UP)
.multiply(BigDecimal.valueOf(100))
.doubleValue();
}
/**
* @return true si ce plan est recommandé
*/
public Boolean isRecommended() {
return metadata != null && Boolean.TRUE.equals(metadata.get("recommended"));
}
/**
* @return true si ce plan est populaire
*/
public Boolean isPopular() {
return metadata != null && Boolean.TRUE.equals(metadata.get("popular"));
}
// ===== SOUS-CLASSES POUR RESPONSES SPÉCIFIQUES =====
/**
* Version publique pour les pages de pricing (sans IDs Stripe)
*/
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public static class Public {
private UUID id;
private String name;
private String type;
private BigDecimal monthlyPrice;
private BigDecimal yearlyPrice;
private Integer maxUsers;
private Set<String> features;
private Integer trialDurationDays;
private Integer displayOrder;
private Boolean isRecommended;
private Boolean isPopular;
private BigDecimal yearlySavings;
private Double yearlySavingsPercentage;
private Boolean hasUnlimitedUsers;
private Integer featureCount;
}
/**
* Version administrative complète
*/
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public static class Admin extends PlanDto {
// Statistiques d'utilisation
private Long activeSubscriptions;
private Long totalSubscriptions;
private BigDecimal monthlyRecurringRevenue;
private BigDecimal annualRecurringRevenue;
// Données de gestion
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
private LocalDateTime lastSyncWithStripe;
private Boolean needsStripeSync;
}
/**
* Version pour comparaison de plans
*/
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public static class Comparison {
private UUID id;
private String name;
private BigDecimal monthlyPrice;
private BigDecimal yearlyPrice;
private Integer maxUsers;
private Set<String> features;
private Boolean isRecommended;
private Boolean isPopular;
private Boolean hasUnlimitedUsers;
private Integer featureCount;
private BigDecimal yearlySavings;
private Double yearlySavingsPercentage;
// Comparaison relative
private String relativePricing; // "cheapest", "most-expensive", "mid-range"
private Boolean bestValue;
private String targetAudience; // "individual", "small-team", "enterprise"
}
}

View File

@ -0,0 +1,168 @@
package com.dh7789dev.xpeditis.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonInclude;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
/**
* DTO pour les abonnements dans les réponses API
*/
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public class SubscriptionDto {
private UUID id;
@NotBlank(message = "L'ID Stripe de l'abonnement est obligatoire")
private String stripeSubscriptionId;
@NotBlank(message = "L'ID client Stripe est obligatoire")
private String stripeCustomerId;
@NotBlank(message = "L'ID prix Stripe est obligatoire")
private String stripePriceId;
@NotNull(message = "Le statut est obligatoire")
private String status;
@NotNull(message = "Le début de période courante est obligatoire")
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
private LocalDateTime currentPeriodStart;
@NotNull(message = "La fin de période courante est obligatoire")
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
private LocalDateTime currentPeriodEnd;
private Boolean cancelAtPeriodEnd = false;
@NotNull(message = "Le cycle de facturation est obligatoire")
private String billingCycle;
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
private LocalDateTime nextBillingDate;
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
private LocalDateTime trialEndDate;
// Relations
private LicenseDto license;
private List<InvoiceDto> invoices;
// Métadonnées
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
private LocalDateTime createdAt;
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
private LocalDateTime updatedAt;
// ===== PROPRIÉTÉS CALCULÉES =====
/**
* @return true si l'abonnement est actif
*/
public Boolean isActive() {
return "ACTIVE".equals(status);
}
/**
* @return true si l'abonnement est en période d'essai
*/
public Boolean isTrialing() {
return "TRIALING".equals(status);
}
/**
* @return true si l'abonnement est en retard de paiement
*/
public Boolean isPastDue() {
return "PAST_DUE".equals(status);
}
/**
* @return true si l'abonnement est annulé
*/
public Boolean isCanceled() {
return "CANCELED".equals(status);
}
/**
* @return le nombre de jours jusqu'à la prochaine facturation
*/
public Long getDaysUntilNextBilling() {
if (nextBillingDate == null) return null;
LocalDateTime now = LocalDateTime.now();
if (now.isAfter(nextBillingDate)) return 0L;
return java.time.temporal.ChronoUnit.DAYS.between(now, nextBillingDate);
}
/**
* @return true si l'abonnement nécessite une attention
*/
public Boolean requiresAttention() {
return "PAST_DUE".equals(status) ||
"UNPAID".equals(status) ||
"INCOMPLETE".equals(status) ||
"INCOMPLETE_EXPIRED".equals(status);
}
/**
* @return le nombre de jours restants dans la période d'essai
*/
public Long getTrialDaysRemaining() {
if (!"TRIALING".equals(status) || trialEndDate == null) return null;
LocalDateTime now = LocalDateTime.now();
if (now.isAfter(trialEndDate)) return 0L;
return java.time.temporal.ChronoUnit.DAYS.between(now, trialEndDate);
}
// ===== SOUS-CLASSES POUR RESPONSES SPÉCIFIQUES =====
/**
* Version minimale pour les listes
*/
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public static class Summary {
private UUID id;
private String stripeSubscriptionId;
private String status;
private String billingCycle;
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
private LocalDateTime nextBillingDate;
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
private LocalDateTime createdAt;
private Boolean requiresAttention;
private Long daysUntilNextBilling;
}
/**
* Version détaillée pour les vues individuelles
*/
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public static class Detailed extends SubscriptionDto {
// Métriques additionnelles
private Integer totalInvoices;
private Integer unpaidInvoices;
private String planName;
private String companyName;
// Prochaines actions
private String nextAction;
private LocalDateTime nextActionDate;
private String nextActionDescription;
}
}

View File

@ -0,0 +1,59 @@
package com.dh7789dev.xpeditis.dto.request;
import com.fasterxml.jackson.annotation.JsonInclude;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.util.UUID;
/**
* DTO pour les requêtes de création d'abonnement
*/
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public class CreateSubscriptionRequest {
@NotNull(message = "L'ID de l'entreprise est obligatoire")
private UUID companyId;
@NotBlank(message = "L'ID du plan est obligatoire")
private String planType;
@NotBlank(message = "Le cycle de facturation est obligatoire")
private String billingCycle; // MONTHLY ou YEARLY
private UUID paymentMethodId;
// Options d'essai
private Boolean startTrial = true;
private Integer customTrialDays;
// Options de prorata
private Boolean enableProration = true;
// Métadonnées personnalisées
private String couponCode;
private String referralSource;
private String salesRepresentative;
// Validation
public boolean isValidBillingCycle() {
return "MONTHLY".equals(billingCycle) || "YEARLY".equals(billingCycle);
}
}
/**
* Réponse pour la création d'abonnement
*/
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
class CreateSubscriptionResponse {
private UUID subscriptionId;
private String stripeSubscriptionId;
private String status;
private String clientSecret; // Pour confirmer le paiement côté client si nécessaire
private String nextAction; // Action requise (ex: authentification 3D Secure)
private Boolean requiresPaymentMethod;
private String hostedCheckoutUrl; // URL pour finaliser le paiement
}

View File

@ -0,0 +1,44 @@
package com.dh7789dev.xpeditis.dto.request;
import com.fasterxml.jackson.annotation.JsonInclude;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
import java.util.UUID;
/**
* DTO pour les requêtes de mise à jour d'abonnement
*/
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public class UpdateSubscriptionRequest {
// Changement de plan
private String newPlanType;
private String newBillingCycle;
// Changement de méthode de paiement
private UUID newPaymentMethodId;
// Gestion de l'annulation
private Boolean cancelAtPeriodEnd;
private String cancellationReason;
// Options de prorata pour les changements
private Boolean enableProration = true;
private Boolean immediateChange = false;
// Validation
public boolean isValidBillingCycle() {
return newBillingCycle == null ||
"MONTHLY".equals(newBillingCycle) ||
"YEARLY".equals(newBillingCycle);
}
public boolean hasChanges() {
return newPlanType != null ||
newBillingCycle != null ||
newPaymentMethodId != null ||
cancelAtPeriodEnd != null;
}
}

View File

@ -0,0 +1,85 @@
package com.dh7789dev.xpeditis.mapper;
import com.dh7789dev.xpeditis.dto.SubscriptionDto;
import com.dh7789dev.xpeditis.dto.app.Subscription;
import org.springframework.stereotype.Component;
/**
* Mapper manuel pour convertir entre Subscription (domain) et SubscriptionDto (application)
*/
@Component
public class SubscriptionDtoMapper {
/**
* Convertit un domaine Subscription en DTO
*/
public SubscriptionDto toDto(Subscription subscription) {
if (subscription == null) {
return null;
}
SubscriptionDto dto = new SubscriptionDto();
dto.setId(subscription.getId());
dto.setStripeSubscriptionId(subscription.getStripeSubscriptionId());
dto.setStripeCustomerId(subscription.getStripeCustomerId());
dto.setStripePriceId(subscription.getStripePriceId());
dto.setStatus(subscription.getStatus() != null ? subscription.getStatus().name() : null);
dto.setCurrentPeriodStart(subscription.getCurrentPeriodStart());
dto.setCurrentPeriodEnd(subscription.getCurrentPeriodEnd());
dto.setCancelAtPeriodEnd(subscription.isCancelAtPeriodEnd());
dto.setBillingCycle(subscription.getBillingCycle() != null ? subscription.getBillingCycle().name() : null);
dto.setNextBillingDate(subscription.getNextBillingDate());
dto.setTrialEndDate(subscription.getTrialEndDate());
dto.setCreatedAt(subscription.getCreatedAt());
dto.setUpdatedAt(subscription.getUpdatedAt());
return dto;
}
/**
* Convertit un domaine Subscription en DTO Summary
*/
public SubscriptionDto.Summary toSummaryDto(Subscription subscription) {
if (subscription == null) {
return null;
}
SubscriptionDto.Summary summary = new SubscriptionDto.Summary();
summary.setId(subscription.getId());
summary.setStripeSubscriptionId(subscription.getStripeSubscriptionId());
summary.setStatus(subscription.getStatus() != null ? subscription.getStatus().name() : null);
summary.setBillingCycle(subscription.getBillingCycle() != null ? subscription.getBillingCycle().name() : null);
summary.setNextBillingDate(subscription.getNextBillingDate());
summary.setCreatedAt(subscription.getCreatedAt());
summary.setRequiresAttention(subscription.requiresAttention());
summary.setDaysUntilNextBilling(subscription.getDaysUntilNextBilling());
return summary;
}
/**
* Convertit un domaine Subscription en DTO Detailed
*/
public SubscriptionDto.Detailed toDetailedDto(Subscription subscription) {
if (subscription == null) {
return null;
}
SubscriptionDto.Detailed detailed = new SubscriptionDto.Detailed();
detailed.setId(subscription.getId());
detailed.setStripeSubscriptionId(subscription.getStripeSubscriptionId());
detailed.setStripeCustomerId(subscription.getStripeCustomerId());
detailed.setStripePriceId(subscription.getStripePriceId());
detailed.setStatus(subscription.getStatus() != null ? subscription.getStatus().name() : null);
detailed.setCurrentPeriodStart(subscription.getCurrentPeriodStart());
detailed.setCurrentPeriodEnd(subscription.getCurrentPeriodEnd());
detailed.setCancelAtPeriodEnd(subscription.isCancelAtPeriodEnd());
detailed.setBillingCycle(subscription.getBillingCycle() != null ? subscription.getBillingCycle().name() : null);
detailed.setNextBillingDate(subscription.getNextBillingDate());
detailed.setTrialEndDate(subscription.getTrialEndDate());
detailed.setCreatedAt(subscription.getCreatedAt());
detailed.setUpdatedAt(subscription.getUpdatedAt());
return detailed;
}
}

View File

@ -10,7 +10,7 @@ import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
@ComponentScan(basePackages = {"com.dh7789dev.xpeditis"})
@EnableJpaRepositories("com.dh7789dev.xpeditis.dao")
@EnableJpaRepositories({"com.dh7789dev.xpeditis.dao", "com.dh7789dev.xpeditis.repository"})
@EntityScan("com.dh7789dev.xpeditis.entity")
@EnableJpaAuditing(auditorAwareRef = "auditorProvider")
@SpringBootApplication

View File

@ -1,6 +1,7 @@
package com.dh7789dev.xpeditis.configuration;
import com.dh7789dev.xpeditis.dao.UserDao;
import com.dh7789dev.xpeditis.entity.UserEntity;
import org.springframework.context.MessageSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

View File

@ -0,0 +1,19 @@
package com.dh7789dev.xpeditis.configuration;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
import lombok.Data;
@Configuration
@ConfigurationProperties(prefix = "spring.security.oauth2.client.registration.google")
@Data
public class OAuth2Configuration {
private String clientId;
private String clientSecret;
private String redirectUri;
private String scope;
public static final String GOOGLE_TOKEN_INFO_URL = "https://www.googleapis.com/oauth2/v1/tokeninfo?access_token=%s";
public static final String GOOGLE_USER_INFO_URL = "https://www.googleapis.com/oauth2/v2/userinfo?access_token=%s";
}

View File

@ -0,0 +1,14 @@
package com.dh7789dev.xpeditis.configuration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate;
@Configuration
public class RestTemplateConfiguration {
@Bean
public RestTemplate restTemplate() {
return new RestTemplate();
}
}

View File

@ -11,6 +11,7 @@ import org.springframework.security.authentication.dao.DaoAuthenticationProvider
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer;
import org.springframework.security.core.context.SecurityContextHolder;
@ -27,7 +28,7 @@ import static org.springframework.security.web.util.matcher.AntPathRequestMatche
@Configuration
@EnableWebSecurity
//@EnableMethodSecurity
@EnableMethodSecurity(prePostEnabled = true)
public class SecurityConfiguration {
@Value("${application.csrf.enabled}")
@ -40,8 +41,18 @@ public class SecurityConfiguration {
private static final String[] WHITE_LIST_URL = {
"/api/v1/auth/**",
"/api/v1/devis/**",
"/api/v1/grilles-tarifaires/**",
"/actuator/health/**"};
private static final String[] ADMIN_ONLY_URL = {
"/api/v1/users"};
private static final String[] GENERAL_API_URL = {
"/api/v1/quotes/**",
"/api/v1/shipments/**",
"/api/v1/companies/profile"};
private static final String[] INTERNAL_WHITE_LIST_URL = {
"/v2/api-docs",
"/v3/api-docs",
@ -99,10 +110,9 @@ public class SecurityConfiguration {
http.authorizeHttpRequests(auth ->
auth.requestMatchers(WHITE_LIST_URL).permitAll()
.requestMatchers(antMatcher(HttpMethod.GET, "/")).permitAll()
.requestMatchers(antMatcher(HttpMethod.GET, API_V1_URI)).permitAll()
.requestMatchers(antMatcher(HttpMethod.PUT, API_V1_URI)).hasRole(ADMIN_ROLE)
.requestMatchers(antMatcher(HttpMethod.DELETE, API_V1_URI)).hasRole(ADMIN_ROLE)
.requestMatchers(antMatcher(HttpMethod.POST, API_V1_URI)).hasRole(ADMIN_ROLE)
.requestMatchers("/api/v1/users/**").authenticated()
.requestMatchers("/api/v1/profile/**").authenticated()
.requestMatchers(GENERAL_API_URL).authenticated()
.requestMatchers(antMatcher("/h2-console/**")).access(INTERNAL_ACCESS)
.requestMatchers(antMatcher("/actuator/**")).access(INTERNAL_ACCESS)
.requestMatchers(INTERNAL_WHITE_LIST_URL).access(INTERNAL_ACCESS)

View File

@ -1,14 +1,13 @@
---
spring:
h2:
console:
enabled: 'false'
enabled: ${SPRING_H2_CONSOLE_ENABLED:false}
datasource:
url: jdbc:h2:mem:xpeditis;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
driverClassName: org.h2.Driver
username: sa
password: ''
url: ${SPRING_H2_DATASOURCE_URL:jdbc:h2:mem:xpeditis;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE}
driverClassName: ${SPRING_H2_DATASOURCE_DRIVER_CLASS_NAME:org.h2.Driver}
username: ${SPRING_H2_DATASOURCE_USERNAME:sa}
password: ${SPRING_H2_DATASOURCE_PASSWORD:}
sql:
init:
@ -16,45 +15,74 @@ spring:
mode: always
jpa:
show-sql: true
show-sql: ${SPRING_JPA_SHOW_SQL:false}
properties:
hibernate:
format_sql: true
format_sql: ${SPRING_JPA_FORMAT_SQL:false}
# show_sql: true
database-platform: org.hibernate.dialect.H2Dialect
# database-platform: ${SPRING_JPA_DATABASE_PLATFORM_H2:org.hibernate.dialect.H2Dialect}
hibernate:
ddl-auto: update
ddl-auto: ${SPRING_JPA_HIBERNATE_DDL_AUTO_DEV:create-drop}
# Just to load initial data for the demo. DO NOT USE IT IN PRODUCTION
defer-datasource-initialization: true
defer-datasource-initialization: ${SPRING_JPA_DEFER_DATASOURCE_INITIALIZATION_DEV:true}
flyway: # flyway automatically uses the datasource from the application to connect to the DB
enabled: false # enables flyway database migration
enabled: ${SPRING_FLYWAY_ENABLED_DEV:false} # enables flyway database migration
mail:
host: sandbox.smtp.mailtrap.io
port: 2525
username: 2597bd31d265eb
password: cd126234193c89
host: ${SPRING_MAIL_HOST_DEV:sandbox.smtp.mailtrap.io}
port: ${SPRING_MAIL_PORT_DEV:2525}
username: ${SPRING_MAIL_USERNAME_DEV:your_mailtrap_username}
password: ${SPRING_MAIL_PASSWORD_DEV:your_mailtrap_password}
properties:
mail:
smtp:
ssl:
trust:"*"
auth: true
trust: ${SPRING_MAIL_SMTP_SSL_TRUST:*}
auth: ${SPRING_MAIL_SMTP_AUTH:true}
starttls:
enable: true
connectiontimeout: 5000
timeout: 3000
writetimeout: 5000
enable: ${SPRING_MAIL_SMTP_STARTTLS_ENABLE:true}
connectiontimeout: ${SPRING_MAIL_SMTP_CONNECTION_TIMEOUT:5000}
timeout: ${SPRING_MAIL_SMTP_TIMEOUT:3000}
writetimeout: ${SPRING_MAIL_SMTP_WRITE_TIMEOUT:5000}
security:
oauth2:
client:
registration:
google:
client-id: ${GOOGLE_CLIENT_ID:your-google-client-id}
client-secret: ${GOOGLE_CLIENT_SECRET:your-google-client-secret}
scope: ${GOOGLE_OAUTH2_SCOPE:openid,email,profile}
redirect-uri: ${OAUTH2_REDIRECT_URI_DEV:http://localhost:8080/login/oauth2/code/google}
provider:
google:
authorization-uri: ${GOOGLE_AUTHORIZATION_URI:https://accounts.google.com/o/oauth2/v2/auth}
token-uri: ${GOOGLE_TOKEN_URI:https://oauth2.googleapis.com/token}
user-info-uri: ${GOOGLE_USER_INFO_URI:https://www.googleapis.com/oauth2/v2/userinfo}
user-name-attribute: ${GOOGLE_USER_NAME_ATTRIBUTE:sub}
application:
email:
from: randommailjf@gmail.com
from: ${APPLICATION_EMAIL_FROM_DEV:noreply@xpeditis.local}
csrf:
enabled: false
enabled: ${APPLICATION_CSRF_ENABLED_DEV:false}
security:
jwt:
secret-key: 404E635266556A586E3272357538782F413F4428472B4B6250645367566B5970
expiration: 86400000 # a day
secret-key: ${JWT_SECRET_KEY:404E635266556A586E3272357538782F413F4428472B4B6250645367566B5970}
expiration: ${JWT_EXPIRATION:86400000} # a day
refresh-token:
expiration: 604800000 # 7 days
expiration: ${JWT_REFRESH_TOKEN_EXPIRATION:604800000} # 7 days
oauth2:
google:
enabled: ${APPLICATION_OAUTH2_GOOGLE_ENABLED:true}
license:
trial:
duration-days: ${APPLICATION_LICENSE_TRIAL_DURATION_DAYS:30}
max-users: ${APPLICATION_LICENSE_TRIAL_MAX_USERS:5}
basic:
max-users: ${APPLICATION_LICENSE_BASIC_MAX_USERS:50}
premium:
max-users: ${APPLICATION_LICENSE_PREMIUM_MAX_USERS:200}
enterprise:
max-users: ${APPLICATION_LICENSE_ENTERPRISE_MAX_USERS:1000}

View File

@ -1,10 +1,9 @@
---
spring:
datasource:
url: ${SPRING_DATASOURCE_URL}
driver-class-name: com.mysql.cj.jdbc.Driver
username: ${SPRING_DATASOURCE_USERNAME}
password: ${SPRING_DATASOURCE_PASSWORD}
url: ${SPRING_DATASOURCE_URL:}
driver-class-name: ${SPRING_DATASOURCE_DRIVER_CLASS_NAME:com.mysql.cj.jdbc.Driver}
username: ${SPRING_DATASOURCE_USERNAME:}
password: ${SPRING_DATASOURCE_PASSWORD:}
#hikari:
#schema: leblr
@ -15,52 +14,83 @@ spring:
#data-locations: import_users.sql
jpa:
# show-sql: true
show-sql: ${SPRING_JPA_SHOW_SQL:false}
properties:
hibernate:
format_sql: true
format_sql: ${SPRING_JPA_FORMAT_SQL:true}
#show_sql: true
database: mysql
database-platform: org.hibernate.dialect.MySQLDialect
database-platform: ${SPRING_JPA_DATABASE_PLATFORM_MYSQL:org.hibernate.dialect.MySQLDialect}
hibernate:
ddl-auto: validate
defer-datasource-initialization: false
ddl-auto: ${SPRING_JPA_HIBERNATE_DDL_AUTO_PROD:validate}
defer-datasource-initialization: ${SPRING_JPA_DEFER_DATASOURCE_INITIALIZATION_PROD:false}
#open-in-view: false
flyway: # flyway automatically uses the datasource from the application to connect to the DB
enabled: true # enables flyway database migration
locations: classpath:db/migration/structure, classpath:db/migration/data # the location where flyway should look for migration scripts
validate-on-migrate: true
baseline-on-migrate: true
baseline-version: 0
default-schema: leblr
enabled: ${SPRING_FLYWAY_ENABLED_PROD:true} # enables flyway database migration
locations: ${SPRING_FLYWAY_LOCATIONS:classpath:db/migration/structure, classpath:db/migration/data} # the location where flyway should look for migration scripts
validate-on-migrate: ${SPRING_FLYWAY_VALIDATE_ON_MIGRATE:true}
baseline-on-migrate: ${SPRING_FLYWAY_BASELINE_ON_MIGRATE:true}
baseline-version: ${SPRING_FLYWAY_BASELINE_VERSION:0}
default-schema: ${SPRING_FLYWAY_DEFAULT_SCHEMA:leblr}
security:
oauth2:
client:
registration:
google:
client-id: ${GOOGLE_CLIENT_ID}
client-secret: ${GOOGLE_CLIENT_SECRET}
scope: ${GOOGLE_OAUTH2_SCOPE:openid,email,profile}
redirect-uri: ${OAUTH2_REDIRECT_URI_PROD:https://xpeditis.fr/login/oauth2/code/google}
provider:
google:
authorization-uri: ${GOOGLE_AUTHORIZATION_URI:https://accounts.google.com/o/oauth2/v2/auth}
token-uri: ${GOOGLE_TOKEN_URI:https://oauth2.googleapis.com/token}
user-info-uri: ${GOOGLE_USER_INFO_URI:https://www.googleapis.com/oauth2/v2/userinfo}
user-name-attribute: ${GOOGLE_USER_NAME_ATTRIBUTE:sub}
mail:
protocol: smtp
host: ssl0.ovh.net
port: 587
username: contact@xpeditis.fr
password:
protocol: ${SPRING_MAIL_PROTOCOL_PROD:smtp}
host: ${SPRING_MAIL_HOST_PROD:ssl0.ovh.net}
port: ${SPRING_MAIL_PORT_PROD:587}
username: ${SPRING_MAIL_USERNAME_PROD:contact@xpeditis.fr}
password: ${SPRING_MAIL_PASSWORD_PROD:}
properties:
mail:
smtp:
auth: true
auth: ${SPRING_MAIL_SMTP_AUTH:true}
starttls:
enable: true
connectiontimeout: 5000
timeout: 3000
writetimeout: 5000
enable: ${SPRING_MAIL_SMTP_STARTTLS_ENABLE:true}
connectiontimeout: ${SPRING_MAIL_SMTP_CONNECTION_TIMEOUT:5000}
timeout: ${SPRING_MAIL_SMTP_TIMEOUT:3000}
writetimeout: ${SPRING_MAIL_SMTP_WRITE_TIMEOUT:5000}
application:
email:
from: contact@leblr.fr
from: ${APPLICATION_EMAIL_FROM_PROD:contact@xpeditis.fr}
csrf:
enabled: false
enabled: ${APPLICATION_CSRF_ENABLED_PROD:true}
security:
jwt:
secret-key: 404E635266556A586E3272357538782F413F4428472B4B6250645367566B5970
expiration: 86400000 # a day
secret-key: ${JWT_SECRET_KEY}
expiration: ${JWT_EXPIRATION:86400000} # a day
refresh-token:
expiration: 604800000 # 7 days
expiration: ${JWT_REFRESH_TOKEN_EXPIRATION:604800000} # 7 days
oauth2:
google:
enabled: ${APPLICATION_OAUTH2_GOOGLE_ENABLED:true}
license:
trial:
duration-days: ${APPLICATION_LICENSE_TRIAL_DURATION_DAYS:30}
max-users: ${APPLICATION_LICENSE_TRIAL_MAX_USERS:5}
basic:
max-users: ${APPLICATION_LICENSE_BASIC_MAX_USERS:50}
premium:
max-users: ${APPLICATION_LICENSE_PREMIUM_MAX_USERS:200}
enterprise:
max-users: ${APPLICATION_LICENSE_ENTERPRISE_MAX_USERS:1000}

View File

@ -1,8 +1,8 @@
server:
port: 8080
port: ${SERVER_PORT:8080}
file:
upload-dir: /upload
upload-dir: ${FILE_UPLOAD_DIR:/upload}
spring:
http:
@ -12,11 +12,11 @@ spring:
force: true
application:
name: '@project.description@'
version: '@project.version@'
name: ${SPRING_APPLICATION_NAME:@project.description@}
version: ${SPRING_APPLICATION_VERSION:@project.version@}
profiles:
active: '@spring.profiles.active@'
active: ${SPRING_PROFILES_ACTIVE:@spring.profiles.active@}
banner:
location: 'classpath:banner.txt'
@ -30,21 +30,22 @@ spring:
servlet:
multipart:
enabled: true
max-file-size: 50MB
max-request-size: 50MB
enabled: ${SPRING_SERVLET_MULTIPART_ENABLED:true}
max-file-size: ${SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE:50MB}
max-request-size: ${SPRING_SERVLET_MULTIPART_MAX_REQUEST_SIZE:50MB}
#location: ${java.io.tmpdir}
logging:
level:
root: ${LOGGING_LEVEL_ROOT:INFO}
org:
org.hibernate.orm.query.sqm.ast.logTree: OFF
org.hibernate.orm.query.sqm.ast.logTree: ${LOGGING_LEVEL_HIBERNATE_SQL:OFF}
springframework:
boot:
autoconfigure: OFF
autoconfigure: ${LOGGING_LEVEL_SPRINGFRAMEWORK_BOOT_AUTOCONFIGURE:OFF}
web:
filter:
CommonsRequestLoggingFilter: INFO
CommonsRequestLoggingFilter: ${LOGGING_LEVEL_COMMONS_REQUEST_LOGGING_FILTER:INFO}
security:
config:
annotation:

View File

@ -1,14 +0,0 @@
package com.dh7789dev.xpeditis;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
@SpringBootTest
@ActiveProfiles("dev")
class LeBlrApplicationTests {
@Test
void contextLoads() {
}
}

View File

@ -1,12 +1,24 @@
package com.dh7789dev.xpeditis;
import com.dh7789dev.xpeditis.dto.app.UserAccount;
import com.dh7789dev.xpeditis.dto.request.AuthenticationRequest;
import com.dh7789dev.xpeditis.dto.response.AuthenticationResponse;
import com.dh7789dev.xpeditis.dto.request.RegisterRequest;
import com.dh7789dev.xpeditis.dto.response.AuthenticationResponse;
import com.dh7789dev.xpeditis.port.in.AuthenticationUseCase;
public interface AuthenticationService {
public interface AuthenticationService extends AuthenticationUseCase {
AuthenticationResponse authenticate(AuthenticationRequest request);
AuthenticationResponse register(RegisterRequest request);
AuthenticationResponse authenticateWithGoogle(String googleToken);
UserAccount getCurrentUser(String token);
void logout(String token);
boolean validateToken(String token);
AuthenticationResponse refreshToken(String refreshToken);
}

View File

@ -1,4 +1,24 @@
package com.dh7789dev.xpeditis;
import com.dh7789dev.xpeditis.dto.app.Company;
import com.dh7789dev.xpeditis.dto.app.License;
import com.dh7789dev.xpeditis.dto.request.CreateCompanyRequest;
import com.dh7789dev.xpeditis.dto.request.UpdateCompanyRequest;
import com.dh7789dev.xpeditis.dto.app.LicenseType;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface CompanyService {
Company createCompany(CreateCompanyRequest request);
Company updateCompany(UUID id, UpdateCompanyRequest request);
Optional<Company> findCompanyById(UUID id);
List<Company> findAllCompanies();
void deleteCompany(UUID id);
boolean validateLicense(UUID companyId, int requestedUsers);
boolean isLicenseActive(UUID companyId);
License upgradeLicense(UUID companyId, LicenseType newLicenseType);
int getMaxUsersForLicense(LicenseType licenseType);
}

View File

@ -0,0 +1,23 @@
package com.dh7789dev.xpeditis;
import com.dh7789dev.xpeditis.dto.app.DemandeDevis;
import com.dh7789dev.xpeditis.dto.app.ReponseDevis;
public interface DevisCalculService {
/**
* Calcule les 3 offres (Rapide, Standard, Économique) pour une demande de devis
*
* @param demandeDevis la demande de devis avec tous les détails
* @return une réponse contenant les 3 offres calculées
*/
ReponseDevis calculerTroisOffres(DemandeDevis demandeDevis);
/**
* Valide qu'une demande de devis contient toutes les informations nécessaires
*
* @param demandeDevis la demande à valider
* @throws IllegalArgumentException si des données obligatoires sont manquantes
*/
void validerDemandeDevis(DemandeDevis demandeDevis);
}

View File

@ -0,0 +1,88 @@
package com.dh7789dev.xpeditis;
import com.dh7789dev.xpeditis.dto.app.GrilleTarifaire;
import com.dh7789dev.xpeditis.dto.app.DemandeDevis;
import java.util.List;
public interface GrilleTarifaireService {
/**
* Trouve toutes les grilles tarifaires applicables pour une demande de devis
*
* @param demandeDevis la demande de devis
* @return liste des grilles applicables
*/
List<GrilleTarifaire> trouverGrillesApplicables(DemandeDevis demandeDevis);
/**
* Crée ou met à jour une grille tarifaire
*
* @param grilleTarifaire la grille à sauvegarder
* @return la grille sauvegardée
*/
GrilleTarifaire sauvegarderGrille(GrilleTarifaire grilleTarifaire);
/**
* Trouve une grille tarifaire par son ID
*
* @param id l'identifiant de la grille
* @return la grille trouvée ou null
*/
GrilleTarifaire trouverParId(Long id);
/**
* Supprime une grille tarifaire
*
* @param id l'identifiant de la grille à supprimer
*/
void supprimerGrille(Long id);
/**
* Importe des grilles tarifaires depuis un fichier CSV
*
* @param file le fichier CSV
* @param mode le mode d'import (MERGE ou REPLACE)
* @return la liste des grilles importées
*/
List<GrilleTarifaire> importerDepuisCsv(org.springframework.web.multipart.MultipartFile file, String mode) throws java.io.IOException;
/**
* Importe des grilles tarifaires depuis un fichier Excel
*
* @param file le fichier Excel
* @param sheetName le nom de la feuille (optionnel)
* @param mode le mode d'import (MERGE ou REPLACE)
* @return la liste des grilles importées
*/
List<GrilleTarifaire> importerDepuisExcel(org.springframework.web.multipart.MultipartFile file, String sheetName, String mode) throws java.io.IOException;
/**
* Importe des grilles tarifaires depuis du JSON
*
* @param grilles la liste des grilles au format JSON
* @param mode le mode d'import (MERGE ou REPLACE)
* @return la liste des grilles importées
*/
List<GrilleTarifaire> importerDepuisJson(List<GrilleTarifaire> grilles, String mode);
/**
* Liste les grilles tarifaires avec pagination et filtres
*
* @param page numéro de page
* @param size taille de la page
* @param transporteur filtre par transporteur
* @param paysOrigine filtre par pays d'origine
* @param paysDestination filtre par pays de destination
* @return la liste des grilles correspondant aux critères
*/
List<GrilleTarifaire> listerGrilles(int page, int size, String transporteur, String paysOrigine, String paysDestination);
/**
* Valide la structure et le contenu d'un fichier d'import
*
* @param file le fichier à valider
* @return les résultats de validation
*/
java.util.Map<String, Object> validerFichier(org.springframework.web.multipart.MultipartFile file) throws java.io.IOException;
}

View File

@ -1,4 +1,25 @@
package com.dh7789dev.xpeditis;
public interface LicenseService {
import com.dh7789dev.xpeditis.dto.app.Company;
import com.dh7789dev.xpeditis.dto.app.License;
import com.dh7789dev.xpeditis.dto.app.LicenseType;
import com.dh7789dev.xpeditis.port.in.LicenseValidationUseCase;
import java.util.UUID;
public interface LicenseService extends LicenseValidationUseCase {
boolean validateLicense(UUID companyId);
boolean canAddUser(UUID companyId);
License createTrialLicense(Company company);
License upgradeLicense(UUID companyId, LicenseType newType);
void deactivateLicense(UUID licenseId);
License getActiveLicense(UUID companyId);
long getDaysUntilExpiration(UUID companyId);
}

View File

@ -0,0 +1,91 @@
package com.dh7789dev.xpeditis;
import com.dh7789dev.xpeditis.dto.app.Plan;
import com.dh7789dev.xpeditis.dto.app.LicenseType;
import com.dh7789dev.xpeditis.dto.app.BillingCycle;
import com.dh7789dev.xpeditis.dto.valueobject.Money;
import com.dh7789dev.xpeditis.port.in.PlanManagementUseCase;
import java.util.List;
import java.util.UUID;
/**
* Service de gestion des plans d'abonnement
*/
public interface PlanService extends PlanManagementUseCase {
/**
* Récupère tous les plans actifs
*/
List<Plan> getAllActivePlans();
/**
* Récupère un plan par son ID
*/
Plan getPlanById(UUID planId);
/**
* Récupère un plan par son type
*/
Plan getPlanByType(LicenseType type);
/**
* Récupère un plan par son ID Stripe
*/
Plan getPlanByStripePriceId(String stripePriceId);
/**
* Calcule le montant prorata pour un changement de plan
*/
Money calculateProrata(Plan currentPlan, Plan newPlan, BillingCycle cycle, int daysRemaining);
/**
* Trouve les plans adaptés à un nombre d'utilisateurs
*/
List<Plan> findSuitablePlansForUserCount(int userCount);
/**
* Compare deux plans et retourne les différences
*/
PlanComparison comparePlans(Plan plan1, Plan plan2);
/**
* Vérifie si un upgrade/downgrade est possible
*/
boolean canChangePlan(Plan currentPlan, Plan targetPlan, int currentUserCount);
/**
* Récupère le plan recommandé pour une entreprise
*/
Plan getRecommendedPlan(int userCount, List<String> requiredFeatures);
/**
* Classe interne pour la comparaison de plans
*/
class PlanComparison {
private final Plan plan1;
private final Plan plan2;
private final List<String> addedFeatures;
private final List<String> removedFeatures;
private final Money priceDifference;
private final boolean isUpgrade;
public PlanComparison(Plan plan1, Plan plan2, List<String> addedFeatures,
List<String> removedFeatures, Money priceDifference, boolean isUpgrade) {
this.plan1 = plan1;
this.plan2 = plan2;
this.addedFeatures = addedFeatures;
this.removedFeatures = removedFeatures;
this.priceDifference = priceDifference;
this.isUpgrade = isUpgrade;
}
// Getters
public Plan getPlan1() { return plan1; }
public Plan getPlan2() { return plan2; }
public List<String> getAddedFeatures() { return addedFeatures; }
public List<String> getRemovedFeatures() { return removedFeatures; }
public Money getPriceDifference() { return priceDifference; }
public boolean isUpgrade() { return isUpgrade; }
}
}

View File

@ -1,10 +1,44 @@
package com.dh7789dev.xpeditis;
import com.dh7789dev.xpeditis.dto.app.UserAccount;
import com.dh7789dev.xpeditis.dto.request.ChangePasswordRequest;
import com.dh7789dev.xpeditis.dto.request.RegisterRequest;
import com.dh7789dev.xpeditis.port.in.UserManagementUseCase;
import java.security.Principal;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface UserService {
public interface UserService extends UserManagementUseCase {
void changePassword(ChangePasswordRequest request, Principal connectedUser);
UserAccount createUser(RegisterRequest request);
UserAccount createGoogleUser(String googleToken);
Optional<UserAccount> findById(UUID id);
Optional<UserAccount> findByEmail(String email);
Optional<UserAccount> findByUsername(String username);
UserAccount updateProfile(UserAccount userAccount);
void deactivateUser(UUID userId);
void deleteUser(UUID userId);
boolean existsByEmail(String email);
boolean existsByUsername(String username);
List<UserAccount> findAllUsers(int page, int size);
List<UserAccount> findUsersByCompany(UUID companyId, int page, int size);
long countAllUsers();
long countUsersByCompany(UUID companyId);
}

View File

@ -0,0 +1,20 @@
package com.dh7789dev.xpeditis.port.in;
import com.dh7789dev.xpeditis.dto.app.UserAccount;
import com.dh7789dev.xpeditis.dto.request.AuthenticationRequest;
import com.dh7789dev.xpeditis.dto.response.AuthenticationResponse;
public interface AuthenticationUseCase {
AuthenticationResponse authenticate(AuthenticationRequest request);
AuthenticationResponse authenticateWithGoogle(String googleToken);
UserAccount getCurrentUser(String token);
void logout(String token);
boolean validateToken(String token);
AuthenticationResponse refreshToken(String refreshToken);
}

View File

@ -0,0 +1,24 @@
package com.dh7789dev.xpeditis.port.in;
import com.dh7789dev.xpeditis.dto.app.Company;
import com.dh7789dev.xpeditis.dto.app.License;
import com.dh7789dev.xpeditis.dto.app.LicenseType;
import java.util.UUID;
public interface LicenseValidationUseCase {
boolean validateLicense(UUID companyId);
boolean canAddUser(UUID companyId);
License createTrialLicense(Company company);
License upgradeLicense(UUID companyId, LicenseType newType);
void deactivateLicense(UUID licenseId);
License getActiveLicense(UUID companyId);
long getDaysUntilExpiration(UUID companyId);
}

View File

@ -0,0 +1,33 @@
package com.dh7789dev.xpeditis.port.in;
import com.dh7789dev.xpeditis.dto.app.Plan;
import com.dh7789dev.xpeditis.dto.app.LicenseType;
import java.util.List;
import java.util.UUID;
/**
* Port d'entrée pour la gestion des plans d'abonnement
*/
public interface PlanManagementUseCase {
/**
* Récupère tous les plans disponibles
*/
List<Plan> getAllActivePlans();
/**
* Récupère un plan par son ID
*/
Plan getPlanById(UUID planId);
/**
* Récupère un plan par son type de licence
*/
Plan getPlanByType(LicenseType type);
/**
* Trouve les plans adaptés à un nombre d'utilisateurs
*/
List<Plan> findSuitablePlansForUserCount(int userCount);
}

View File

@ -0,0 +1,38 @@
package com.dh7789dev.xpeditis.port.in;
import com.dh7789dev.xpeditis.dto.app.Subscription;
import com.dh7789dev.xpeditis.dto.app.Plan;
import com.dh7789dev.xpeditis.dto.app.BillingCycle;
import java.util.UUID;
/**
* Port d'entrée pour la gestion des abonnements
*/
public interface SubscriptionManagementUseCase {
/**
* Crée un abonnement avec un plan et cycle de facturation
*/
Subscription createSubscription(UUID companyId, Plan plan, BillingCycle billingCycle);
/**
* Change le plan d'un abonnement existant
*/
Subscription changePlan(UUID companyId, Plan newPlan, boolean immediate);
/**
* Annule un abonnement
*/
Subscription cancelSubscription(UUID companyId, boolean immediate, String reason);
/**
* Récupère l'abonnement actif d'une entreprise
*/
Subscription getActiveSubscription(UUID companyId);
/**
* Réactive un abonnement suspendu ou annulé
*/
Subscription reactivateSubscription(UUID companyId);
}

View File

@ -0,0 +1,98 @@
package com.dh7789dev.xpeditis.port.in;
import com.dh7789dev.xpeditis.dto.app.Subscription;
import com.dh7789dev.xpeditis.dto.app.SubscriptionStatus;
import com.dh7789dev.xpeditis.dto.app.Plan;
import com.dh7789dev.xpeditis.dto.app.BillingCycle;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
/**
* Service de gestion des abonnements avec intégration Stripe
*/
public interface SubscriptionService extends SubscriptionManagementUseCase {
/**
* Crée un abonnement Stripe avec tous les paramètres
*/
Subscription createSubscription(UUID companyId, String planType, String billingCycle,
String paymentMethodId, Boolean startTrial, Integer customTrialDays);
/**
* Met à jour un abonnement depuis un webhook Stripe
*/
Subscription updateSubscriptionFromStripe(String stripeSubscriptionId, SubscriptionStatus newStatus);
/**
* Recherche les abonnements avec filtres
*/
List<Subscription> findSubscriptions(String status, String billingCycle, String customerId, int page, int size);
/**
* Trouve un abonnement par ID
*/
Optional<Subscription> findById(UUID subscriptionId);
/**
* Change le plan d'un abonnement
*/
Subscription changeSubscriptionPlan(UUID subscriptionId, String newPlanType, String newBillingCycle,
Boolean enableProration, Boolean immediateChange);
/**
* Met à jour le moyen de paiement
*/
void updatePaymentMethod(UUID subscriptionId, String newPaymentMethodId);
/**
* Planifie l'annulation à la fin de période
*/
Subscription scheduleForCancellation(UUID subscriptionId, String cancellationReason);
/**
* Annule un abonnement immédiatement
*/
Subscription cancelSubscriptionImmediately(UUID subscriptionId, String reason);
/**
* Réactive un abonnement annulé
*/
Subscription reactivateSubscription(UUID subscriptionId);
/**
* Trouve les abonnements par entreprise
*/
List<Subscription> findByCompanyId(UUID companyId);
/**
* Récupère les abonnements nécessitant une attention
*/
List<Subscription> findSubscriptionsRequiringAttention();
/**
* Trouve les essais qui se terminent bientôt
*/
List<Subscription> findTrialsEndingSoon(int daysAhead);
/**
* Traite un échec de paiement
*/
void handlePaymentFailure(String stripeSubscriptionId, String reason);
/**
* Traite un paiement réussi
*/
void handlePaymentSuccess(String stripeSubscriptionId, String stripeInvoiceId);
/**
* Démarre la période de grâce pour un abonnement
*/
void startGracePeriod(UUID subscriptionId);
/**
* Suspend les abonnements impayés
*/
void suspendUnpaidSubscriptions();
}

View File

@ -0,0 +1,34 @@
package com.dh7789dev.xpeditis.port.in;
import com.dh7789dev.xpeditis.dto.app.UserAccount;
import com.dh7789dev.xpeditis.dto.request.RegisterRequest;
import com.dh7789dev.xpeditis.dto.request.ChangePasswordRequest;
import java.security.Principal;
import java.util.Optional;
import java.util.UUID;
public interface UserManagementUseCase {
UserAccount createUser(RegisterRequest request);
UserAccount createGoogleUser(String googleToken);
Optional<UserAccount> findById(UUID id);
Optional<UserAccount> findByEmail(String email);
Optional<UserAccount> findByUsername(String username);
UserAccount updateProfile(UserAccount userAccount);
void changePassword(ChangePasswordRequest request, Principal connectedUser);
void deactivateUser(UUID userId);
void deleteUser(UUID userId);
boolean existsByEmail(String email);
boolean existsByUsername(String username);
}

View File

@ -0,0 +1,6 @@
package com.dh7789dev.xpeditis.dto.app;
public enum AuthProvider {
LOCAL,
GOOGLE
}

View File

@ -0,0 +1,52 @@
package com.dh7789dev.xpeditis.dto.app;
/**
* Cycles de facturation disponibles
*/
public enum BillingCycle {
/**
* Facturation mensuelle
*/
MONTHLY("month", 1),
/**
* Facturation annuelle
*/
YEARLY("year", 12);
private final String stripeInterval;
private final int months;
BillingCycle(String stripeInterval, int months) {
this.stripeInterval = stripeInterval;
this.months = months;
}
/**
* @return L'intervalle Stripe correspondant
*/
public String getStripeInterval() {
return stripeInterval;
}
/**
* @return Le nombre de mois pour ce cycle
*/
public int getMonths() {
return months;
}
/**
* @return true si c'est un cycle mensuel
*/
public boolean isMonthly() {
return this == MONTHLY;
}
/**
* @return true si c'est un cycle annuel
*/
public boolean isYearly() {
return this == YEARLY;
}
}

View File

@ -1,19 +1,50 @@
package com.dh7789dev.xpeditis.dto.app;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Company {
private Long id;
private UUID id;
private String name;
private String description;
private String website;
private String industry;
private String country;
private String siren;
private String numEori;
private String phone;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private boolean isActive;
private List<UserAccount> users;
private List<License> licenses;
private License license; // Current active license
private List<Quote> quotes;
private List<ExportFolder> exports;
public License getActiveLicense() {
return licenses != null ? licenses.stream()
.filter(License::isActive)
.filter(license -> license.getExpirationDate() == null ||
license.getExpirationDate().isAfter(LocalDateTime.now().toLocalDate()))
.findFirst()
.orElse(null) : null;
}
public int getActiveUserCount() {
return users != null ? (int) users.stream()
.filter(UserAccount::isActive)
.count() : 0;
}
}

View File

@ -0,0 +1,119 @@
package com.dh7789dev.xpeditis.dto.app;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDate;
import java.util.List;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class DemandeDevis {
private String typeService;
private String incoterm;
private String typeLivraison;
// Adresses
private AdresseTransport depart;
private AdresseTransport arrivee;
// Douane
private String douaneImportExport;
private Boolean eur1Import;
private Boolean eur1Export;
// Colisage
private List<Colisage> colisages;
// Contraintes et services
private MarchandiseDangereuse marchandiseDangereuse;
private ManutentionParticuliere manutentionParticuliere;
private ProduitsReglementes produitsReglementes;
private ServicesAdditionnels servicesAdditionnels;
// Dates
private LocalDate dateEnlevement;
private LocalDate dateLivraison;
// Client
private String nomClient;
private String emailClient;
@Data
@AllArgsConstructor
@NoArgsConstructor
public static class AdresseTransport {
private String ville;
private String codePostal;
private String pays;
private String coordonneesGps;
}
@Data
@AllArgsConstructor
@NoArgsConstructor
public static class Colisage {
private TypeColisage type;
private Integer quantite;
private Double longueur;
private Double largeur;
private Double hauteur;
private Double poids;
private Boolean gerbable;
public Double getVolume() {
if (longueur != null && largeur != null && hauteur != null) {
return longueur * largeur * hauteur / 1000000; // cm³ to m³
}
return 0.0;
}
public enum TypeColisage {
CAISSE, COLIS, PALETTE, AUTRES
}
}
@Data
@AllArgsConstructor
@NoArgsConstructor
public static class MarchandiseDangereuse {
private String type;
private String classe;
private String unNumber;
private String description;
}
@Data
@AllArgsConstructor
@NoArgsConstructor
public static class ManutentionParticuliere {
private Boolean hayon;
private Boolean sangles;
private Boolean couvertureThermique;
private String autres;
}
@Data
@AllArgsConstructor
@NoArgsConstructor
public static class ProduitsReglementes {
private Boolean alimentaire;
private Boolean pharmaceutique;
private String autres;
}
@Data
@AllArgsConstructor
@NoArgsConstructor
public static class ServicesAdditionnels {
private Boolean rendezVousLivraison;
private Boolean documentT1;
private Boolean stopDouane;
private Boolean assistanceExport;
private Boolean assurance;
private Double valeurDeclaree;
}
}

View File

@ -0,0 +1,52 @@
package com.dh7789dev.xpeditis.dto.app;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class DocumentSummaryDto {
private Long id;
// ========== DOCUMENT TYPE ==========
private Long typeDocumentId;
private String typeDocumentNom;
private boolean isObligatoire;
// ========== FILE INFO ==========
private String nomOriginal;
private Long tailleOctets;
private String typeMime;
private Integer numeroVersion;
// ========== VALIDATION ==========
private StatutVerification statutVerification;
private String displayStatutVerification;
private String commentaireVerification;
private String correctionsDemandees;
// ========== METADATA ==========
private String description;
private LocalDate dateValidite;
private boolean isExpired;
// ========== USER INFO ==========
private String uploadePar;
private LocalDateTime dateUpload;
private String verifiePar;
private LocalDateTime dateVerification;
// ========== ACTIONS ==========
private boolean canDownload;
private boolean canDelete;
private boolean canValidate;
private boolean requiresAdminAction;
}

View File

@ -0,0 +1,167 @@
package com.dh7789dev.xpeditis.dto.app;
import java.util.Set;
/**
* Énumération des statuts d'un dossier d'export
* Définit le workflow complet de traitement des dossiers
*/
public enum DossierStatus {
// ========== PHASE CRÉATION ==========
CREE("Créé", "Dossier créé depuis devis accepté", false, false),
// ========== PHASE DOCUMENTS ==========
DOCUMENTS_EN_ATTENTE("Documents en attente", "En attente d'upload des documents obligatoires", true, false),
DOCUMENTS_UPLOADES("Documents uploadés", "Documents uploadés, en attente de vérification", true, false),
DOCUMENTS_EN_VERIFICATION("Documents en vérification", "Vérification des documents en cours par admin", false, false),
DOCUMENTS_REFUSES("Documents refusés", "Corrections demandées sur un ou plusieurs documents", true, false),
DOCUMENTS_VALIDES("Documents validés", "Tous documents validés, prêt pour booking", false, false),
// ========== PHASE TRANSPORT ==========
BOOKING_EN_COURS("Booking en cours", "Réservation transport en cours", false, false),
BOOKING_CONFIRME("Booking confirmé", "Transport confirmé", false, false),
ENLEVE("Enlevé", "Marchandise enlevée", false, false),
EN_TRANSIT("En transit", "En cours de transport", false, false),
ARRIVE("Arrivé", "Arrivé à destination", false, false),
// ========== PHASE FINALISATION ==========
LIVRE("Livré", "Livré au destinataire", false, true),
CLOTURE("Clôturé", "Dossier clôturé", false, true),
// ========== STATUT SPÉCIAL ==========
ANNULE("Annulé", "Dossier annulé", false, true);
private final String displayName;
private final String description;
private final boolean allowsDocumentUpload;
private final boolean isFinalized;
DossierStatus(String displayName, String description, boolean allowsDocumentUpload, boolean isFinalized) {
this.displayName = displayName;
this.description = description;
this.allowsDocumentUpload = allowsDocumentUpload;
this.isFinalized = isFinalized;
}
public String getDisplayName() {
return displayName;
}
public String getDescription() {
return description;
}
public boolean allowsDocumentUpload() {
return allowsDocumentUpload;
}
public boolean isFinalized() {
return isFinalized;
}
/**
* Retourne les statuts qui permettent l'upload de documents
*/
public static Set<DossierStatus> getUploadableStatuses() {
return Set.of(
DOCUMENTS_EN_ATTENTE,
DOCUMENTS_UPLOADES,
DOCUMENTS_REFUSES
);
}
/**
* Retourne les statuts considérés comme actifs (non terminés)
*/
public static Set<DossierStatus> getActiveStatuses() {
return Set.of(
CREE,
DOCUMENTS_EN_ATTENTE,
DOCUMENTS_UPLOADES,
DOCUMENTS_EN_VERIFICATION,
DOCUMENTS_REFUSES,
DOCUMENTS_VALIDES,
BOOKING_EN_COURS,
BOOKING_CONFIRME,
ENLEVE,
EN_TRANSIT,
ARRIVE
);
}
/**
* Vérifie si une transition de statut est valide
*/
public boolean canTransitionTo(DossierStatus newStatus) {
if (this.isFinalized) {
return false; // Aucune transition possible depuis un statut finalisé
}
// Définition des transitions valides
switch (this) {
case CREE:
return newStatus == DOCUMENTS_EN_ATTENTE || newStatus == ANNULE;
case DOCUMENTS_EN_ATTENTE:
return newStatus == DOCUMENTS_UPLOADES || newStatus == ANNULE;
case DOCUMENTS_UPLOADES:
return newStatus == DOCUMENTS_EN_VERIFICATION ||
newStatus == DOCUMENTS_EN_ATTENTE || newStatus == ANNULE;
case DOCUMENTS_EN_VERIFICATION:
return newStatus == DOCUMENTS_VALIDES ||
newStatus == DOCUMENTS_REFUSES || newStatus == ANNULE;
case DOCUMENTS_REFUSES:
return newStatus == DOCUMENTS_UPLOADES ||
newStatus == DOCUMENTS_EN_VERIFICATION || newStatus == ANNULE;
case DOCUMENTS_VALIDES:
return newStatus == BOOKING_EN_COURS || newStatus == ANNULE;
case BOOKING_EN_COURS:
return newStatus == BOOKING_CONFIRME ||
newStatus == DOCUMENTS_VALIDES || newStatus == ANNULE;
case BOOKING_CONFIRME:
return newStatus == ENLEVE || newStatus == ANNULE;
case ENLEVE:
return newStatus == EN_TRANSIT;
case EN_TRANSIT:
return newStatus == ARRIVE;
case ARRIVE:
return newStatus == LIVRE;
case LIVRE:
return newStatus == CLOTURE;
default:
return false;
}
}
/**
* Retourne le prochain statut logique dans le workflow standard
*/
public DossierStatus getNextLogicalStatus() {
switch (this) {
case CREE: return DOCUMENTS_EN_ATTENTE;
case DOCUMENTS_EN_ATTENTE: return DOCUMENTS_UPLOADES;
case DOCUMENTS_UPLOADES: return DOCUMENTS_EN_VERIFICATION;
case DOCUMENTS_EN_VERIFICATION: return DOCUMENTS_VALIDES;
case DOCUMENTS_REFUSES: return DOCUMENTS_UPLOADES;
case DOCUMENTS_VALIDES: return BOOKING_EN_COURS;
case BOOKING_EN_COURS: return BOOKING_CONFIRME;
case BOOKING_CONFIRME: return ENLEVE;
case ENLEVE: return EN_TRANSIT;
case EN_TRANSIT: return ARRIVE;
case ARRIVE: return LIVRE;
case LIVRE: return CLOTURE;
default: return null;
}
}
}

View File

@ -0,0 +1,31 @@
package com.dh7789dev.xpeditis.dto.app;
/**
* Statuts de traitement des événements webhook
*/
public enum EventStatus {
/**
* Événement reçu mais pas encore traité
*/
PENDING,
/**
* Événement traité avec succès
*/
PROCESSED,
/**
* Échec du traitement de l'événement
*/
FAILED,
/**
* Événement en cours de traitement
*/
PROCESSING,
/**
* Événement ignoré (déjà traité ou non pertinent)
*/
IGNORED
}

View File

@ -0,0 +1,79 @@
package com.dh7789dev.xpeditis.dto.app;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Set;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ExportFolderDto {
private Long id;
// ========== BASIC INFO ==========
private String reference;
private String numeroDossier;
private DossierStatus statut;
private String displayStatut;
// ========== COMPANY & QUOTE ==========
private Long companyId;
private String companyName;
private Long quoteId;
private String quoteReference;
// ========== WORKFLOW DATES ==========
private LocalDateTime dateCreation;
private LocalDateTime dateDocumentsComplets;
private LocalDateTime dateValidationDocuments;
private LocalDateTime dateBooking;
private LocalDateTime dateEnlevement;
private LocalDateTime dateLivraison;
private LocalDateTime dateCloture;
// ========== TRANSPORT REFERENCES ==========
private String referenceBooking;
private String numeroBl;
private String numeroConteneur;
// ========== METADATA ==========
private String commentairesClient;
private String notesInternes;
// ========== USER ASSIGNMENTS ==========
private Long createdById;
private String createdByName;
private Long assignedToAdminId;
private String assignedToAdminName;
// ========== WORKFLOW INFO ==========
private boolean allowsDocumentUpload;
private boolean isFinalized;
private DossierStatus nextLogicalStatus;
private boolean canTransitionToNext;
// ========== DOCUMENTS SUMMARY ==========
private int totalDocuments;
private int validatedDocuments;
private int pendingDocuments;
private int rejectedDocuments;
private boolean allMandatoryDocumentsProvided;
// ========== PERMISSIONS ==========
private Set<FolderAction> authorizedActions;
// ========== RELATED DATA ==========
private List<DocumentSummaryDto> documents;
private List<HistoryEntryDto> recentHistory;
// ========== TIMESTAMPS ==========
private LocalDateTime createdAt;
private LocalDateTime modifiedAt;
}

View File

@ -0,0 +1,90 @@
package com.dh7789dev.xpeditis.dto.app;
/**
* Actions possibles sur les dossiers d'export
* Utilisé pour le système de permissions granulaires
*/
public enum FolderAction {
// ========== CONSULTATION ==========
VIEW_BASIC_INFO("Consulter informations de base", "Voir référence, statut, dates"),
VIEW_FULL_DETAILS("Consulter détails complets", "Voir toutes les informations du dossier"),
VIEW_DOCUMENTS("Consulter documents", "Voir la liste des documents uploadés"),
VIEW_HISTORY("Consulter historique", "Voir l'historique des actions"),
DOWNLOAD_DOCUMENTS("Télécharger documents", "Télécharger les fichiers"),
// ========== MODIFICATION ==========
UPDATE_BASIC_INFO("Modifier informations de base", "Modifier commentaires client, références"),
UPDATE_STATUS("Modifier statut", "Changer le statut du dossier"),
ADD_INTERNAL_NOTES("Ajouter notes internes", "Ajouter des commentaires internes"),
// ========== GESTION DOCUMENTS ==========
UPLOAD_DOCUMENTS("Uploader documents", "Ajouter de nouveaux documents"),
DELETE_DOCUMENTS("Supprimer documents", "Supprimer des documents"),
VALIDATE_DOCUMENTS("Valider documents", "Approuver ou refuser des documents"),
REQUEST_CORRECTIONS("Demander corrections", "Demander des corrections sur documents"),
// ========== WORKFLOW ==========
TRANSITION_STATUS("Changer statut workflow", "Faire progresser le dossier dans le workflow"),
FORCE_STATUS_CHANGE("Forcer changement statut", "Changer le statut sans validation workflow"),
ASSIGN_TO_ADMIN("Assigner à administrateur", "Assigner le dossier à un admin"),
MARK_DOCUMENTS_COMPLETE("Marquer documents complets", "Valider que tous documents sont fournis"),
// ========== ADMINISTRATION ==========
DELETE_FOLDER("Supprimer dossier", "Supprimer complètement le dossier"),
EXPORT_DATA("Exporter données", "Exporter les données du dossier"),
MANAGE_PERMISSIONS("Gérer permissions", "Modifier les permissions sur le dossier"),
ACCESS_SYSTEM_DATA("Accès données système", "Voir données techniques internes"),
// ========== TRANSPORT ==========
UPDATE_TRANSPORT_INFO("Modifier infos transport", "Mettre à jour booking, BL, conteneur"),
TRACK_SHIPMENT("Suivre expédition", "Voir le suivi de l'expédition"),
UPDATE_TRACKING("Mettre à jour suivi", "Ajouter des mises à jour de suivi"),
// ========== NOTIFICATIONS ==========
RECEIVE_NOTIFICATIONS("Recevoir notifications", "Être notifié des changements"),
SEND_NOTIFICATIONS("Envoyer notifications", "Notifier d'autres utilisateurs");
private final String displayName;
private final String description;
FolderAction(String displayName, String description) {
this.displayName = displayName;
this.description = description;
}
public String getDisplayName() {
return displayName;
}
public String getDescription() {
return description;
}
/**
* Actions de consultation seulement
*/
public boolean isReadOnlyAction() {
return this == VIEW_BASIC_INFO || this == VIEW_FULL_DETAILS ||
this == VIEW_DOCUMENTS || this == VIEW_HISTORY ||
this == DOWNLOAD_DOCUMENTS || this == TRACK_SHIPMENT ||
this == RECEIVE_NOTIFICATIONS;
}
/**
* Actions nécessitant des privilèges administratifs
*/
public boolean requiresAdminPrivileges() {
return this == VALIDATE_DOCUMENTS || this == FORCE_STATUS_CHANGE ||
this == DELETE_FOLDER || this == MANAGE_PERMISSIONS ||
this == ACCESS_SYSTEM_DATA || this == ASSIGN_TO_ADMIN;
}
/**
* Actions liées au workflow
*/
public boolean isWorkflowAction() {
return this == UPDATE_STATUS || this == TRANSITION_STATUS ||
this == FORCE_STATUS_CHANGE || this == MARK_DOCUMENTS_COMPLETE;
}
}

View File

@ -0,0 +1,27 @@
package com.dh7789dev.xpeditis.dto.app;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class FraisAdditionnels {
private Long id;
private Long grilleId;
private String typeFrais;
private String description;
private BigDecimal montant;
private UniteFacturation uniteFacturation;
private BigDecimal montantMinimum;
private Boolean obligatoire;
private Boolean applicableMarchandiseDangereuse;
public enum UniteFacturation {
LS, KG, M3, PALETTE, POURCENTAGE
}
}

View File

@ -0,0 +1,21 @@
package com.dh7789dev.xpeditis.dto.app;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class GoogleUserInfo {
private String id;
private String email;
private String firstName;
private String lastName;
private String picture;
private boolean verified;
private String locale;
}

View File

@ -0,0 +1,51 @@
package com.dh7789dev.xpeditis.dto.app;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDate;
import java.util.List;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class GrilleTarifaire {
private Long id;
private String nomGrille;
private String transporteur;
private TypeService typeService;
private String originePays;
private String origineVille;
private String originePortCode;
private String destinationPays;
private String destinationVille;
private String destinationPortCode;
private String incoterm;
private ModeTransport modeTransport;
private ServiceType serviceType;
private Integer transitTimeMin;
private Integer transitTimeMax;
private LocalDate validiteDebut;
private LocalDate validiteFin;
private String devise;
private Boolean actif;
private String deviseBase;
private String commentaires;
private List<TarifFret> tarifsFret;
private List<FraisAdditionnels> fraisAdditionnels;
private List<SurchargeDangereuse> surchargesDangereuses;
public enum TypeService {
IMPORT, EXPORT
}
public enum ModeTransport {
MARITIME, AERIEN, ROUTIER, FERROVIAIRE
}
public enum ServiceType {
RAPIDE, STANDARD, ECONOMIQUE
}
}

View File

@ -0,0 +1,43 @@
package com.dh7789dev.xpeditis.dto.app;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.Map;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class HistoryEntryDto {
private Long id;
// ========== ACTION INFO ==========
private String action;
private String description;
// ========== STATUS CHANGES ==========
private String ancienStatut;
private String nouveauStatut;
// ========== ACTOR INFO ==========
private String effectueParType; // COMPANY_USER, ADMIN, SYSTEM
private Long effectueParId;
private String effectueParNom;
// ========== TIMING ==========
private LocalDateTime dateAction;
private String relativeTime; // "Il y a 2 heures", "Hier", etc.
// ========== ADDITIONAL DATA ==========
private Map<String, Object> donneesSupplementaires;
// ========== UI HELPERS ==========
private String actionIcon; // Icon CSS class for UI
private String actionColor; // Color for UI display
private String displayText; // Human-readable display text
}

View File

@ -0,0 +1,133 @@
package com.dh7789dev.xpeditis.dto.app;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
/**
* Facture générée pour un abonnement
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Invoice {
private UUID id;
private String stripeInvoiceId;
private String invoiceNumber;
private Subscription subscription;
private InvoiceStatus status;
private BigDecimal amountDue;
private BigDecimal amountPaid;
private String currency;
private LocalDateTime billingPeriodStart;
private LocalDateTime billingPeriodEnd;
private LocalDateTime dueDate;
private LocalDateTime paidAt;
private String invoicePdfUrl;
private String hostedInvoiceUrl;
private Integer attemptCount;
private List<InvoiceLineItem> lineItems;
private LocalDateTime createdAt;
/**
* @return true si la facture est payée
*/
public boolean isPaid() {
return status == InvoiceStatus.PAID;
}
/**
* @return true si la facture est en attente de paiement
*/
public boolean isOpen() {
return status == InvoiceStatus.OPEN;
}
/**
* @return true si la facture est en retard
*/
public boolean isOverdue() {
return isOpen() && dueDate != null && LocalDateTime.now().isAfter(dueDate);
}
/**
* @return true si la facture est annulée
*/
public boolean isVoided() {
return status == InvoiceStatus.VOID;
}
/**
* @return le montant restant à payer
*/
public BigDecimal getAmountRemaining() {
if (amountDue == null) return BigDecimal.ZERO;
if (amountPaid == null) return amountDue;
return amountDue.subtract(amountPaid);
}
/**
* @return true si la facture est entièrement payée
*/
public boolean isFullyPaid() {
return getAmountRemaining().compareTo(BigDecimal.ZERO) <= 0;
}
/**
* @return le nombre de jours depuis la date d'échéance
*/
public long getDaysOverdue() {
if (dueDate == null || !isOverdue()) return 0;
return java.time.temporal.ChronoUnit.DAYS.between(dueDate, LocalDateTime.now());
}
/**
* @return le nombre de jours jusqu'à l'échéance (négatif si en retard)
*/
public long getDaysUntilDue() {
if (dueDate == null) return Long.MAX_VALUE;
return java.time.temporal.ChronoUnit.DAYS.between(LocalDateTime.now(), dueDate);
}
/**
* @return la période de facturation au format texte
*/
public String getBillingPeriodDescription() {
if (billingPeriodStart == null || billingPeriodEnd == null) {
return "Période inconnue";
}
java.time.format.DateTimeFormatter formatter = java.time.format.DateTimeFormatter.ofPattern("dd/MM/yyyy");
return "Du " + billingPeriodStart.format(formatter) + " au " + billingPeriodEnd.format(formatter);
}
/**
* Incrémente le compteur de tentatives de paiement
*/
public void incrementAttemptCount() {
this.attemptCount = (attemptCount == null ? 0 : attemptCount) + 1;
}
/**
* Marque la facture comme payée
*/
public void markAsPaid(LocalDateTime paidAt, BigDecimal amountPaid) {
this.status = InvoiceStatus.PAID;
this.paidAt = paidAt;
this.amountPaid = amountPaid;
}
/**
* @return true si cette facture nécessite une attention immédiate
*/
public boolean requiresAttention() {
return isOverdue() || (isOpen() && getDaysUntilDue() <= 3);
}
}

View File

@ -0,0 +1,97 @@
package com.dh7789dev.xpeditis.dto.app;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.UUID;
/**
* Ligne d'une facture (détail d'un service facturé)
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class InvoiceLineItem {
private UUID id;
private Invoice invoice;
private String description;
private Integer quantity;
private BigDecimal unitPrice;
private BigDecimal amount;
private String stripePriceId;
private LocalDateTime periodStart;
private LocalDateTime periodEnd;
private boolean prorated;
private LocalDateTime createdAt;
/**
* @return le montant total de cette ligne (quantity * unitPrice)
*/
public BigDecimal getTotalAmount() {
if (amount != null) {
return amount;
}
if (quantity != null && unitPrice != null) {
return unitPrice.multiply(BigDecimal.valueOf(quantity));
}
return BigDecimal.ZERO;
}
/**
* @return true si cette ligne concerne une période de service
*/
public boolean isPeriodBased() {
return periodStart != null && periodEnd != null;
}
/**
* @return la description de la période de service
*/
public String getPeriodDescription() {
if (!isPeriodBased()) {
return null;
}
java.time.format.DateTimeFormatter formatter = java.time.format.DateTimeFormatter.ofPattern("dd/MM/yyyy");
return "Du " + periodStart.format(formatter) + " au " + periodEnd.format(formatter);
}
/**
* @return true si c'est un ajustement prorata
*/
public boolean isProrated() {
return prorated;
}
/**
* @return une description complète de la ligne
*/
public String getFullDescription() {
StringBuilder desc = new StringBuilder();
if (description != null) {
desc.append(description);
}
if (isPeriodBased()) {
if (desc.length() > 0) {
desc.append(" - ");
}
desc.append(getPeriodDescription());
}
if (isProrated()) {
if (desc.length() > 0) {
desc.append(" ");
}
desc.append("(prorata)");
}
return desc.toString();
}
}

View File

@ -0,0 +1,36 @@
package com.dh7789dev.xpeditis.dto.app;
/**
* Statuts possibles d'une facture
*/
public enum InvoiceStatus {
/**
* Facture en brouillon (non finalisée)
*/
DRAFT,
/**
* Facture ouverte en attente de paiement
*/
OPEN,
/**
* Facture payée avec succès
*/
PAID,
/**
* Facture annulée
*/
VOID,
/**
* Facture irrécupérable (après plusieurs tentatives d'échec)
*/
UNCOLLECTIBLE,
/**
* Facture marquée comme non collectible par Stripe
*/
MARKED_UNCOLLECTIBLE
}

View File

@ -1,16 +1,201 @@
package com.dh7789dev.xpeditis.dto.app;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.Set;
import java.util.UUID;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class License {
private Long id;
private UUID id;
private String licenseKey;
private LicenseType type;
private LicenseStatus status;
private LocalDate startDate;
private LocalDate expirationDate;
private boolean active;
private UserAccount user;
private LocalDateTime issuedDate;
private LocalDateTime expiryDate;
private LocalDateTime gracePeriodEndDate;
private int maxUsers;
private Set<String> featuresEnabled;
private boolean isActive;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private LocalDateTime lastCheckedAt;
private Company company;
private Subscription subscription;
/**
* @return true si la licence est expirée (date d'expiration dépassée)
*/
public boolean isExpired() {
return expirationDate != null && expirationDate.isBefore(LocalDate.now());
}
public LocalDateTime getExpiryDate() {
return expiryDate;
}
/**
* @return true si la licence est active (pas suspendue, pas expirée)
*/
public boolean isActive() {
return status == LicenseStatus.ACTIVE;
}
/**
* @return true si la licence est valide (active ou en période de grâce)
*/
public boolean isValid() {
return isActive() || isInGracePeriod();
}
/**
* @return true si la licence est en période de grâce
*/
public boolean isInGracePeriod() {
return status == LicenseStatus.GRACE_PERIOD
&& gracePeriodEndDate != null
&& LocalDateTime.now().isBefore(gracePeriodEndDate);
}
/**
* @return true si la licence est suspendue
*/
public boolean isSuspended() {
return status == LicenseStatus.SUSPENDED;
}
/**
* @return true si la licence est annulée
*/
public boolean isCancelled() {
return status == LicenseStatus.CANCELLED;
}
/**
* @return true si un utilisateur peut être ajouté
*/
public boolean canAddUser(int currentUserCount) {
return isValid() && (!hasUserLimit() || currentUserCount < maxUsers);
}
/**
* @return true si ce type de licence a une limite d'utilisateurs
*/
public boolean hasUserLimit() {
return type != null && type.hasUserLimit();
}
/**
* @return le nombre de jours jusqu'à l'expiration
*/
public long getDaysUntilExpiration() {
return expirationDate != null ?
java.time.temporal.ChronoUnit.DAYS.between(LocalDate.now(), expirationDate) :
Long.MAX_VALUE;
}
/**
* @return le nombre de jours restants en période de grâce (0 si pas en période de grâce)
*/
public long getDaysRemainingInGracePeriod() {
if (!isInGracePeriod() || gracePeriodEndDate == null) {
return 0;
}
return java.time.temporal.ChronoUnit.DAYS.between(LocalDateTime.now(), gracePeriodEndDate);
}
/**
* @return true si la fonctionnalité est activée pour cette licence
*/
public boolean hasFeature(String featureCode) {
return featuresEnabled != null && featuresEnabled.contains(featureCode);
}
/**
* Active une fonctionnalité
*/
public void enableFeature(String featureCode) {
if (featuresEnabled == null) {
featuresEnabled = new java.util.HashSet<>();
}
featuresEnabled.add(featureCode);
this.updatedAt = LocalDateTime.now();
}
/**
* Désactive une fonctionnalité
*/
public void disableFeature(String featureCode) {
if (featuresEnabled != null) {
featuresEnabled.remove(featureCode);
this.updatedAt = LocalDateTime.now();
}
}
/**
* Met à jour le statut de la licence
*/
public void updateStatus(LicenseStatus newStatus) {
this.status = newStatus;
this.updatedAt = LocalDateTime.now();
// Si on sort de la période de grâce, on reset la date
if (newStatus != LicenseStatus.GRACE_PERIOD) {
this.gracePeriodEndDate = null;
}
}
/**
* Démarre la période de grâce
*/
public void startGracePeriod(int gracePeriodDays) {
this.status = LicenseStatus.GRACE_PERIOD;
this.gracePeriodEndDate = LocalDateTime.now().plusDays(gracePeriodDays);
this.updatedAt = LocalDateTime.now();
}
/**
* Suspend la licence
*/
public void suspend() {
this.status = LicenseStatus.SUSPENDED;
this.isActive = false;
this.updatedAt = LocalDateTime.now();
}
/**
* Réactive la licence
*/
public void reactivate() {
this.status = LicenseStatus.ACTIVE;
this.isActive = true;
this.gracePeriodEndDate = null;
this.updatedAt = LocalDateTime.now();
}
/**
* @return true si la licence nécessite une attention immédiate
*/
public boolean requiresAttention() {
return isSuspended()
|| (isInGracePeriod() && getDaysRemainingInGracePeriod() <= 1)
|| (getDaysUntilExpiration() <= 7 && getDaysUntilExpiration() > 0);
}
/**
* Met à jour la dernière vérification
*/
public void updateLastChecked() {
this.lastCheckedAt = LocalDateTime.now();
}
}

View File

@ -0,0 +1,36 @@
package com.dh7789dev.xpeditis.dto.app;
/**
* Statuts possibles d'une licence
*/
public enum LicenseStatus {
/**
* Licence active et valide
*/
ACTIVE,
/**
* Licence expirée mais dans la période de grâce
*/
GRACE_PERIOD,
/**
* Licence suspendue pour non-paiement
*/
SUSPENDED,
/**
* Licence expirée définitivement
*/
EXPIRED,
/**
* Licence annulée par l'utilisateur
*/
CANCELLED,
/**
* Licence révoquée par le système
*/
REVOKED
}

View File

@ -0,0 +1,80 @@
package com.dh7789dev.xpeditis.dto.app;
import java.math.BigDecimal;
public enum LicenseType {
TRIAL(5, 30, "Trial", BigDecimal.ZERO, BigDecimal.ZERO),
BASIC(10, -1, "Basic Plan", BigDecimal.valueOf(29.00), BigDecimal.valueOf(278.00)),
PREMIUM(50, -1, "Premium Plan", BigDecimal.valueOf(79.00), BigDecimal.valueOf(758.00)),
ENTERPRISE(-1, -1, "Enterprise Plan", BigDecimal.valueOf(199.00), BigDecimal.valueOf(1908.00));
private final int maxUsers;
private final int durationDays;
private final String description;
private final BigDecimal basePrice;
private final BigDecimal yearlyPrice;
LicenseType(int maxUsers, int durationDays, String description, BigDecimal basePrice, BigDecimal yearlyPrice) {
this.maxUsers = maxUsers;
this.durationDays = durationDays;
this.description = description;
this.basePrice = basePrice;
this.yearlyPrice = yearlyPrice;
}
public int getMaxUsers() {
return maxUsers;
}
public int getDurationDays() {
return durationDays;
}
public boolean hasUserLimit() {
return maxUsers > 0;
}
public boolean hasTimeLimit() {
return durationDays > 0;
}
public String getDescription() {
return description;
}
public BigDecimal getBasePrice() {
return basePrice;
}
public BigDecimal getYearlyPrice() {
return yearlyPrice;
}
/**
* @return true si c'est un plan gratuit
*/
public boolean isFree() {
return this == TRIAL;
}
/**
* @return true si c'est un plan payant
*/
public boolean isPaid() {
return !isFree();
}
/**
* @return le pourcentage d'économies annuelles
*/
public BigDecimal getYearlySavingsPercentage() {
if (basePrice.compareTo(BigDecimal.ZERO) == 0 || yearlyPrice.compareTo(BigDecimal.ZERO) == 0) {
return BigDecimal.ZERO;
}
BigDecimal yearlyEquivalent = basePrice.multiply(BigDecimal.valueOf(12));
BigDecimal savings = yearlyEquivalent.subtract(yearlyPrice);
return savings.divide(yearlyEquivalent, 4, java.math.RoundingMode.HALF_UP)
.multiply(BigDecimal.valueOf(100));
}
}

View File

@ -0,0 +1,38 @@
package com.dh7789dev.xpeditis.dto.app;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class OffreCalculee {
private String type; // RAPIDE, STANDARD, ECONOMIQUE
private BigDecimal prixTotal;
private String devise;
private String transitTime;
private String transporteur;
private String modeTransport;
private List<String> servicesInclus;
private DetailPrix detailPrix;
private LocalDate validite;
private List<String> conditions;
@Data
@AllArgsConstructor
@NoArgsConstructor
public static class DetailPrix {
private BigDecimal fretBase;
private Map<String, BigDecimal> fraisFixes;
private Map<String, BigDecimal> servicesOptionnels;
private BigDecimal surchargeDangereuse;
private BigDecimal coefficientService;
}
}

View File

@ -0,0 +1,161 @@
package com.dh7789dev.xpeditis.dto.app;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.Map;
import java.util.UUID;
/**
* Événement de paiement reçu via webhook Stripe
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PaymentEvent {
private UUID id;
private String stripeEventId;
private String eventType;
private EventStatus status;
private Map<String, Object> payload;
private LocalDateTime processedAt;
private String errorMessage;
private Integer retryCount;
private Subscription subscription;
private LocalDateTime createdAt;
/**
* @return true si l'événement a été traité avec succès
*/
public boolean isProcessed() {
return status == EventStatus.PROCESSED;
}
/**
* @return true si l'événement a échoué
*/
public boolean isFailed() {
return status == EventStatus.FAILED;
}
/**
* @return true si l'événement est en attente de traitement
*/
public boolean isPending() {
return status == EventStatus.PENDING;
}
/**
* @return true si l'événement est en cours de traitement
*/
public boolean isProcessing() {
return status == EventStatus.PROCESSING;
}
/**
* @return true si l'événement peut être réessayé
*/
public boolean canRetry() {
return isFailed() && (retryCount == null || retryCount < 5);
}
/**
* Incrémente le compteur de tentatives
*/
public void incrementRetryCount() {
this.retryCount = (retryCount == null ? 0 : retryCount) + 1;
}
/**
* Marque l'événement comme traité avec succès
*/
public void markAsProcessed() {
this.status = EventStatus.PROCESSED;
this.processedAt = LocalDateTime.now();
this.errorMessage = null;
}
/**
* Marque l'événement comme échoué
*/
public void markAsFailed(String errorMessage) {
this.status = EventStatus.FAILED;
this.errorMessage = errorMessage;
incrementRetryCount();
}
/**
* Marque l'événement comme en cours de traitement
*/
public void markAsProcessing() {
this.status = EventStatus.PROCESSING;
}
/**
* Marque l'événement comme ignoré
*/
public void markAsIgnored() {
this.status = EventStatus.IGNORED;
this.processedAt = LocalDateTime.now();
}
/**
* @return true si c'est un événement concernant une facture
*/
public boolean isInvoiceEvent() {
return eventType != null && eventType.startsWith("invoice.");
}
/**
* @return true si c'est un événement concernant un abonnement
*/
public boolean isSubscriptionEvent() {
return eventType != null && eventType.startsWith("customer.subscription.");
}
/**
* @return true si c'est un événement concernant un paiement
*/
public boolean isPaymentEvent() {
return eventType != null && (
eventType.startsWith("payment_intent.") ||
eventType.startsWith("invoice.payment_")
);
}
/**
* @return une description lisible de l'événement
*/
public String getEventDescription() {
if (eventType == null) return "Événement inconnu";
switch (eventType) {
case "customer.subscription.created":
return "Abonnement créé";
case "customer.subscription.updated":
return "Abonnement modifié";
case "customer.subscription.deleted":
return "Abonnement supprimé";
case "invoice.payment_succeeded":
return "Paiement réussi";
case "invoice.payment_failed":
return "Paiement échoué";
case "invoice.created":
return "Facture créée";
case "invoice.finalized":
return "Facture finalisée";
case "checkout.session.completed":
return "Session de paiement complétée";
case "payment_method.attached":
return "Méthode de paiement ajoutée";
case "payment_method.detached":
return "Méthode de paiement supprimée";
default:
return eventType;
}
}
}

View File

@ -0,0 +1,145 @@
package com.dh7789dev.xpeditis.dto.app;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.UUID;
/**
* Méthode de paiement d'un client
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PaymentMethod {
private UUID id;
private String stripePaymentMethodId;
private PaymentType type;
private boolean isDefault;
private String cardBrand;
private String cardLast4;
private Integer cardExpMonth;
private Integer cardExpYear;
private String bankName;
private Company company;
private LocalDateTime createdAt;
/**
* @return true si c'est une carte bancaire
*/
public boolean isCard() {
return type == PaymentType.CARD;
}
/**
* @return true si c'est un prélèvement SEPA
*/
public boolean isSepaDebit() {
return type == PaymentType.SEPA_DEBIT;
}
/**
* @return true si c'est un virement bancaire
*/
public boolean isBankTransfer() {
return type == PaymentType.BANK_TRANSFER;
}
/**
* @return true si la carte expire bientôt (dans les 30 jours)
*/
public boolean isCardExpiringSoon() {
if (!isCard() || cardExpMonth == null || cardExpYear == null) {
return false;
}
LocalDateTime now = LocalDateTime.now();
LocalDateTime cardExpiry = LocalDateTime.of(cardExpYear, cardExpMonth, 1, 0, 0)
.plusMonths(1).minusDays(1); // Dernier jour du mois d'expiration
return cardExpiry.isBefore(now.plusDays(30));
}
/**
* @return true si la carte est expirée
*/
public boolean isCardExpired() {
if (!isCard() || cardExpMonth == null || cardExpYear == null) {
return false;
}
LocalDateTime now = LocalDateTime.now();
LocalDateTime cardExpiry = LocalDateTime.of(cardExpYear, cardExpMonth, 1, 0, 0)
.plusMonths(1).minusDays(1); // Dernier jour du mois d'expiration
return cardExpiry.isBefore(now);
}
/**
* @return Une représentation textuelle masquée de la méthode de paiement
*/
public String getDisplayName() {
switch (type) {
case CARD:
if (cardBrand != null && cardLast4 != null) {
return cardBrand.toUpperCase() + " **** **** **** " + cardLast4;
}
return "Carte bancaire";
case SEPA_DEBIT:
if (bankName != null) {
return "SEPA - " + bankName;
}
return "Prélèvement SEPA";
case BANK_TRANSFER:
if (bankName != null) {
return "Virement - " + bankName;
}
return "Virement bancaire";
case PAYPAL:
return "PayPal";
case APPLE_PAY:
return "Apple Pay";
case GOOGLE_PAY:
return "Google Pay";
default:
return type.toString();
}
}
/**
* @return Une description de l'état de la méthode de paiement
*/
public String getStatusDescription() {
if (isCard()) {
if (isCardExpired()) {
return "Carte expirée";
} else if (isCardExpiringSoon()) {
return "Carte expire bientôt";
} else {
return "Carte valide";
}
}
return "Méthode active";
}
/**
* @return true si la méthode de paiement est fonctionnelle
*/
public boolean isFunctional() {
if (isCard()) {
return !isCardExpired();
}
// Pour les autres méthodes, on suppose qu'elles sont fonctionnelles
return true;
}
}

View File

@ -0,0 +1,36 @@
package com.dh7789dev.xpeditis.dto.app;
/**
* Types de moyens de paiement supportés
*/
public enum PaymentType {
/**
* Carte bancaire (Visa, Mastercard, etc.)
*/
CARD,
/**
* Prélèvement SEPA (Europe)
*/
SEPA_DEBIT,
/**
* Virement bancaire
*/
BANK_TRANSFER,
/**
* PayPal
*/
PAYPAL,
/**
* Apple Pay
*/
APPLE_PAY,
/**
* Google Pay
*/
GOOGLE_PAY
}

View File

@ -0,0 +1,94 @@
package com.dh7789dev.xpeditis.dto.app;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
/**
* Plan d'abonnement avec tarification et fonctionnalités
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Plan {
private UUID id;
private String name;
private LicenseType type;
private String stripePriceIdMonthly;
private String stripePriceIdYearly;
private BigDecimal monthlyPrice;
private BigDecimal yearlyPrice;
private Integer maxUsers;
private Set<String> features;
private Integer trialDurationDays;
private boolean isActive;
private Integer displayOrder;
private Map<String, String> metadata;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
/**
* @return Le prix pour le cycle de facturation spécifié
*/
public BigDecimal getPriceForCycle(BillingCycle cycle) {
return cycle.isMonthly() ? monthlyPrice : yearlyPrice;
}
/**
* @return L'ID de prix Stripe pour le cycle spécifié
*/
public String getStripePriceIdForCycle(BillingCycle cycle) {
return cycle.isMonthly() ? stripePriceIdMonthly : stripePriceIdYearly;
}
/**
* @return true si ce plan supporte les trials
*/
public boolean supportsTrials() {
return trialDurationDays != null && trialDurationDays > 0;
}
/**
* @return true si ce plan a une limite d'utilisateurs
*/
public boolean hasUserLimit() {
return type != null && type.hasUserLimit() && maxUsers != null && maxUsers > 0;
}
/**
* @return true si ce plan inclut la fonctionnalité spécifiée
*/
public boolean hasFeature(String feature) {
return features != null && features.contains(feature);
}
/**
* @return Le prix mensuel équivalent (même pour les plans annuels)
*/
public BigDecimal getMonthlyEquivalentPrice(BillingCycle cycle) {
BigDecimal price = getPriceForCycle(cycle);
return cycle.isYearly() ? price.divide(BigDecimal.valueOf(12), 2, java.math.RoundingMode.HALF_UP) : price;
}
/**
* @return Le pourcentage d'économies sur le plan annuel vs mensuel
*/
public BigDecimal getYearlySavingsPercentage() {
if (monthlyPrice == null || yearlyPrice == null || monthlyPrice.compareTo(BigDecimal.ZERO) == 0) {
return BigDecimal.ZERO;
}
BigDecimal yearlyEquivalent = monthlyPrice.multiply(BigDecimal.valueOf(12));
BigDecimal savings = yearlyEquivalent.subtract(yearlyPrice);
return savings.divide(yearlyEquivalent, 4, java.math.RoundingMode.HALF_UP)
.multiply(BigDecimal.valueOf(100));
}
}

View File

@ -0,0 +1,119 @@
package com.dh7789dev.xpeditis.dto.app;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.UUID;
/**
* Fonctionnalité associée à un plan d'abonnement
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PlanFeature {
private UUID id;
private String featureCode;
private String name;
private String description;
private boolean enabled;
private Integer usageLimit;
private String category;
private Integer displayOrder;
private LocalDateTime createdAt;
/**
* Fonctionnalités standard disponibles
*/
public static class Features {
public static final String BASIC_QUOTES = "BASIC_QUOTES";
public static final String ADVANCED_ANALYTICS = "ADVANCED_ANALYTICS";
public static final String BULK_IMPORT = "BULK_IMPORT";
public static final String API_ACCESS = "API_ACCESS";
public static final String PRIORITY_SUPPORT = "PRIORITY_SUPPORT";
public static final String CUSTOM_BRANDING = "CUSTOM_BRANDING";
public static final String MULTI_CURRENCY = "MULTI_CURRENCY";
public static final String DOCUMENT_TEMPLATES = "DOCUMENT_TEMPLATES";
public static final String AUTOMATED_WORKFLOWS = "AUTOMATED_WORKFLOWS";
public static final String TEAM_COLLABORATION = "TEAM_COLLABORATION";
public static final String AUDIT_LOGS = "AUDIT_LOGS";
public static final String CUSTOM_INTEGRATIONS = "CUSTOM_INTEGRATIONS";
public static final String SLA_GUARANTEE = "SLA_GUARANTEE";
public static final String DEDICATED_SUPPORT = "DEDICATED_SUPPORT";
}
/**
* @return true si cette fonctionnalité a une limite d'usage
*/
public boolean hasUsageLimit() {
return usageLimit != null && usageLimit > 0;
}
/**
* @return true si cette fonctionnalité est une fonctionnalité premium
*/
public boolean isPremiumFeature() {
return Features.ADVANCED_ANALYTICS.equals(featureCode)
|| Features.API_ACCESS.equals(featureCode)
|| Features.CUSTOM_BRANDING.equals(featureCode)
|| Features.AUTOMATED_WORKFLOWS.equals(featureCode);
}
/**
* @return true si cette fonctionnalité est réservée à Enterprise
*/
public boolean isEnterpriseFeature() {
return Features.CUSTOM_INTEGRATIONS.equals(featureCode)
|| Features.SLA_GUARANTEE.equals(featureCode)
|| Features.DEDICATED_SUPPORT.equals(featureCode)
|| Features.AUDIT_LOGS.equals(featureCode);
}
/**
* @return la catégorie de la fonctionnalité pour l'affichage
*/
public String getDisplayCategory() {
if (category != null) {
return category;
}
// Catégories par défaut basées sur le code de fonctionnalité
if (featureCode.contains("ANALYTICS") || featureCode.contains("AUDIT")) {
return "Reporting & Analytics";
} else if (featureCode.contains("SUPPORT") || featureCode.contains("SLA")) {
return "Support";
} else if (featureCode.contains("API") || featureCode.contains("INTEGRATION")) {
return "Intégrations";
} else if (featureCode.contains("BRANDING") || featureCode.contains("TEMPLATE")) {
return "Personnalisation";
} else if (featureCode.contains("TEAM") || featureCode.contains("COLLABORATION")) {
return "Collaboration";
} else {
return "Général";
}
}
/**
* @return une description enrichie de la fonctionnalité
*/
public String getEnhancedDescription() {
StringBuilder desc = new StringBuilder();
if (description != null) {
desc.append(description);
}
if (hasUsageLimit()) {
if (desc.length() > 0) {
desc.append(" ");
}
desc.append("(Limite: ").append(usageLimit).append(")");
}
return desc.toString();
}
}

View File

@ -0,0 +1,65 @@
package com.dh7789dev.xpeditis.dto.app;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class ReponseDevis {
private String demandeId;
private ClientInfo client;
private DetailsTransport detailsTransport;
private ColisageResume colisageResume;
private List<OffreCalculee> offres;
private Recommandation recommandation;
private List<String> mentionsLegales;
@Data
@AllArgsConstructor
@NoArgsConstructor
public static class ClientInfo {
private String nom;
private String email;
}
@Data
@AllArgsConstructor
@NoArgsConstructor
public static class DetailsTransport {
private String typeService;
private String incoterm;
private AdresseInfo depart;
private AdresseInfo arrivee;
@Data
@AllArgsConstructor
@NoArgsConstructor
public static class AdresseInfo {
private String adresse;
private String coordonnees;
}
}
@Data
@AllArgsConstructor
@NoArgsConstructor
public static class ColisageResume {
private Integer nombreColis;
private Double poidsTotal;
private Double volumeTotal;
private Double poidsTaxable;
}
@Data
@AllArgsConstructor
@NoArgsConstructor
public static class Recommandation {
private String offreRecommandee;
private String raison;
}
}

View File

@ -0,0 +1,73 @@
package com.dh7789dev.xpeditis.dto.app;
/**
* Système de rôles étendu pour SSC Export System
* Intègre les rôles existants et les nouveaux besoins SSC
*/
public enum Role {
// ========== RÔLES EXISTANTS (Conservés) ==========
USER("Utilisateur Standard", 1),
MANAGER("Gestionnaire", 2),
ADMIN("Administrateur", 3),
ADMIN_PLATFORM("Administrateur Plateforme", 4),
// ========== NOUVEAUX RÔLES SSC ==========
SUPER_ADMIN("Super Administrateur", 10), // Accès total système
ADMIN_SSC("Administrateur SSC", 9), // Validation documents, gestion dossiers
COMPANY_ADMIN("Administrateur Entreprise", 6), // Gestion équipe entreprise cliente
COMPANY_USER("Utilisateur Entreprise", 5), // Consultation dossiers de son entreprise
COMPANY_GUEST("Invité Entreprise", 3); // Lecture seule sur dossiers spécifiques
private final String displayName;
private final int level;
Role(String displayName, int level) {
this.displayName = displayName;
this.level = level;
}
public String getDisplayName() {
return displayName;
}
public int getLevel() {
return level;
}
/**
* Vérifie si ce rôle a un niveau supérieur ou égal à un autre
*/
public boolean hasLevelGreaterOrEqual(Role other) {
return this.level >= other.level;
}
/**
* Vérifie si ce rôle est un rôle SSC (gestion dossiers export)
*/
public boolean isSscRole() {
return this == SUPER_ADMIN || this == ADMIN_SSC ||
this == COMPANY_ADMIN || this == COMPANY_USER || this == COMPANY_GUEST;
}
/**
* Vérifie si ce rôle peut gérer des utilisateurs d'entreprise
*/
public boolean canManageCompanyUsers() {
return this == SUPER_ADMIN || this == ADMIN_SSC || this == COMPANY_ADMIN;
}
/**
* Vérifie si ce rôle peut valider des documents
*/
public boolean canValidateDocuments() {
return this == SUPER_ADMIN || this == ADMIN_SSC;
}
/**
* Vérifie si ce rôle est un administrateur
*/
public boolean isAdmin() {
return this == SUPER_ADMIN || this == ADMIN_SSC ||
this == ADMIN || this == ADMIN_PLATFORM;
}
}

View File

@ -0,0 +1,51 @@
package com.dh7789dev.xpeditis.dto.app;
/**
* Statuts de vérification des documents uploadés
* Cycle de validation par les administrateurs SSC
*/
public enum StatutVerification {
EN_ATTENTE("En attente", "Document uploadé, en attente de vérification"),
EN_COURS_VERIFICATION("En cours de vérification", "Document en cours d'analyse par un administrateur"),
VALIDE("Validé", "Document vérifié et approuvé"),
REFUSE("Refusé", "Document refusé, corrections nécessaires"),
EXPIRE("Expiré", "Document expiré, renouvellement requis");
private final String displayName;
private final String description;
StatutVerification(String displayName, String description) {
this.displayName = displayName;
this.description = description;
}
public String getDisplayName() {
return displayName;
}
public String getDescription() {
return description;
}
/**
* Vérifie si ce statut permet une nouvelle vérification
*/
public boolean allowsReVerification() {
return this == REFUSE || this == EXPIRE;
}
/**
* Vérifie si ce statut est considéré comme final
*/
public boolean isFinal() {
return this == VALIDE || this == REFUSE;
}
/**
* Vérifie si ce statut nécessite une action admin
*/
public boolean requiresAdminAction() {
return this == EN_ATTENTE || this == EN_COURS_VERIFICATION;
}
}

View File

@ -0,0 +1,145 @@
package com.dh7789dev.xpeditis.dto.app;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
/**
* Abonnement d'une entreprise avec intégration Stripe
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Subscription {
private UUID id;
private String stripeSubscriptionId;
private String stripeCustomerId;
private String stripePriceId;
private SubscriptionStatus status;
private LocalDateTime currentPeriodStart;
private LocalDateTime currentPeriodEnd;
private boolean cancelAtPeriodEnd;
private PaymentMethod paymentMethod;
private BillingCycle billingCycle;
private LocalDateTime nextBillingDate;
private LocalDateTime trialEndDate;
private License license;
private List<Invoice> invoices;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
/**
* @return true si l'abonnement est actif
*/
public boolean isActive() {
return status == SubscriptionStatus.ACTIVE;
}
/**
* @return true si l'abonnement est en période d'essai
*/
public boolean isTrialing() {
return status == SubscriptionStatus.TRIALING;
}
/**
* @return true si l'abonnement est en retard de paiement
*/
public boolean isPastDue() {
return status == SubscriptionStatus.PAST_DUE;
}
/**
* @return true si l'abonnement est annulé
*/
public boolean isCanceled() {
return status == SubscriptionStatus.CANCELED;
}
/**
* @return true si l'abonnement est programmé pour annulation
*/
public boolean isScheduledForCancellation() {
return cancelAtPeriodEnd && isActive();
}
/**
* @return true si l'abonnement est encore dans la période d'essai
*/
public boolean isInTrialPeriod() {
return trialEndDate != null && LocalDateTime.now().isBefore(trialEndDate);
}
/**
* @return le nombre de jours restants dans la période d'essai
*/
public long getDaysRemainingInTrial() {
if (trialEndDate == null) return 0;
LocalDateTime now = LocalDateTime.now();
if (now.isAfter(trialEndDate)) return 0;
return java.time.temporal.ChronoUnit.DAYS.between(now, trialEndDate);
}
/**
* @return le nombre de jours jusqu'à la prochaine facturation
*/
public long getDaysUntilNextBilling() {
if (nextBillingDate == null) return Long.MAX_VALUE;
LocalDateTime now = LocalDateTime.now();
if (now.isAfter(nextBillingDate)) return 0;
return java.time.temporal.ChronoUnit.DAYS.between(now, nextBillingDate);
}
/**
* @return true si l'abonnement nécessite une attention (paiement échoué, etc.)
*/
public boolean requiresAttention() {
return status == SubscriptionStatus.PAST_DUE
|| status == SubscriptionStatus.UNPAID
|| status == SubscriptionStatus.INCOMPLETE
|| status == SubscriptionStatus.INCOMPLETE_EXPIRED;
}
/**
* @return true si l'abonnement peut être réactivé
*/
public boolean canBeReactivated() {
return status == SubscriptionStatus.CANCELED
|| status == SubscriptionStatus.PAST_DUE
|| status == SubscriptionStatus.UNPAID;
}
/**
* Met à jour le statut de l'abonnement
*/
public void updateStatus(SubscriptionStatus newStatus) {
this.status = newStatus;
this.updatedAt = LocalDateTime.now();
}
/**
* Programme l'annulation de l'abonnement à la fin de la période
*/
public void scheduleForCancellation() {
this.cancelAtPeriodEnd = true;
this.updatedAt = LocalDateTime.now();
}
/**
* Annule la programmation d'annulation
*/
public void unscheduleForCancellation() {
this.cancelAtPeriodEnd = false;
this.updatedAt = LocalDateTime.now();
}
}

View File

@ -0,0 +1,41 @@
package com.dh7789dev.xpeditis.dto.app;
/**
* Statuts possibles d'un abonnement
*/
public enum SubscriptionStatus {
/**
* Période d'essai en cours
*/
TRIALING,
/**
* Abonnement actif avec paiements à jour
*/
ACTIVE,
/**
* Paiement en retard mais dans la période de grâce
*/
PAST_DUE,
/**
* Abonnement annulé par l'utilisateur ou le système
*/
CANCELED,
/**
* Abonnement suspendu pour impayé (après période de grâce)
*/
UNPAID,
/**
* Abonnement en cours de traitement (création/modification)
*/
INCOMPLETE,
/**
* Abonnement en attente de première action de paiement
*/
INCOMPLETE_EXPIRED
}

View File

@ -0,0 +1,25 @@
package com.dh7789dev.xpeditis.dto.app;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class SurchargeDangereuse {
private Long id;
private Long grilleId;
private String classeAdr;
private String unNumber;
private BigDecimal surcharge;
private UniteFacturation uniteFacturation;
private BigDecimal minimum;
public enum UniteFacturation {
LS, KG, COLIS
}
}

View File

@ -0,0 +1,27 @@
package com.dh7789dev.xpeditis.dto.app;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class TarifFret {
private Long id;
private Long grilleId;
private BigDecimal poidsMin;
private BigDecimal poidsMax;
private BigDecimal volumeMin;
private BigDecimal volumeMax;
private BigDecimal tauxUnitaire;
private UniteFacturation uniteFacturation;
private BigDecimal minimumFacturation;
public enum UniteFacturation {
KG, M3, PALETTE, COLIS, LS
}
}

View File

@ -1,20 +1,51 @@
package com.dh7789dev.xpeditis.dto.app;
import com.dh7789dev.xpeditis.dto.valueobject.Email;
import com.dh7789dev.xpeditis.dto.valueobject.PhoneNumber;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class UserAccount {
private String username;
private UUID id;
private String firstName;
private String lastName;
private String email;
private Email email;
private String username;
private String password;
private String role; // or "ADMIN"
private PhoneNumber phoneNumber;
private AuthProvider authProvider;
private String googleId;
private boolean privacyPolicyAccepted;
private LocalDateTime privacyPolicyAcceptedAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private LocalDateTime lastLoginAt;
private boolean isActive;
private Role role;
private Company company;
private License license;
private List<Quote> quotes;
public String getFullName() {
return (firstName != null ? firstName : "") +
(lastName != null ? " " + lastName : "").trim();
}
public boolean isGoogleAuth() {
return AuthProvider.GOOGLE.equals(authProvider);
}
public boolean isLocalAuth() {
return AuthProvider.LOCAL.equals(authProvider);
}
}

View File

@ -0,0 +1,28 @@
package com.dh7789dev.xpeditis.dto.request;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder(toBuilder = true)
@NoArgsConstructor
@AllArgsConstructor
public class CreateCompanyRequest {
@NotBlank(message = "Company name is required")
@Size(min = 2, max = 100, message = "Company name must be between 2 and 100 characters")
private String name;
@Size(max = 500, message = "Description must not exceed 500 characters")
private String description;
@Size(max = 255, message = "Website must not exceed 255 characters")
private String website;
@Size(max = 100, message = "Industry must not exceed 100 characters")
private String industry;
}

View File

@ -0,0 +1,26 @@
package com.dh7789dev.xpeditis.dto.request;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.experimental.FieldDefaults;
@Data
@Builder(toBuilder = true)
@AllArgsConstructor
@NoArgsConstructor
@FieldDefaults(level = AccessLevel.PRIVATE)
public class CreateFolderRequest {
@NotNull(message = "Quote ID is required")
@Positive(message = "Quote ID must be positive")
Long quoteId;
@Size(max = 1000, message = "Comments must not exceed 1000 characters")
String commentairesClient;
}

View File

@ -0,0 +1,22 @@
package com.dh7789dev.xpeditis.dto.request;
import jakarta.validation.constraints.NotBlank;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.experimental.FieldDefaults;
@Data
@AllArgsConstructor
@NoArgsConstructor
@FieldDefaults(level = AccessLevel.PRIVATE)
public class GoogleAuthRequest {
@NotBlank(message = "Google token is required")
String googleToken;
String companyName;
String phoneNumber;
}

View File

@ -1,41 +1,70 @@
package com.dh7789dev.xpeditis.dto.request;
import jakarta.validation.constraints.NotBlank;
import com.dh7789dev.xpeditis.dto.app.AuthProvider;
import jakarta.validation.constraints.*;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.experimental.Accessors;
import lombok.experimental.FieldDefaults;
@Data
@Builder(toBuilder = true)
@AllArgsConstructor
@NoArgsConstructor
@FieldDefaults(level = AccessLevel.PRIVATE)
@Accessors(chain = true)
public class RegisterRequest {
@NotBlank
@NotBlank(message = "First name is required")
@Size(max = 50, message = "First name must not exceed 50 characters")
String firstName;
@NotBlank
@NotBlank(message = "Last name is required")
@Size(max = 50, message = "Last name must not exceed 50 characters")
String lastName;
@NotBlank
String username;
@NotBlank
@Email(message = "Invalid email format")
@NotBlank(message = "Email is required")
String email;
@NotBlank
@Size(max = 50, message = "Username must not exceed 50 characters")
String username;
@Pattern(
regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d).{8,}$",
message = "Password must be at least 8 characters with uppercase, lowercase and digit"
)
String password;
@NotBlank
String phone;
String confirmPassword;
String company_uuid = "";
@NotBlank(message = "Phone number is required")
@Pattern(
regexp = "^\\+?[1-9]\\d{1,14}$",
message = "Invalid phone number format"
)
String phoneNumber;
String company_name;
@NotBlank(message = "Company name is required")
@Size(max = 100, message = "Company name must not exceed 100 characters")
String companyName;
String companyCountry;
@Builder.Default
AuthProvider authProvider = AuthProvider.LOCAL;
String googleId;
@AssertTrue(message = "Privacy policy must be accepted")
boolean privacyPolicyAccepted;
@AssertTrue(message = "Password confirmation must match")
public boolean isPasswordConfirmed() {
return password != null && password.equals(confirmPassword);
}
}

View File

@ -0,0 +1,26 @@
package com.dh7789dev.xpeditis.dto.request;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class UpdateCompanyRequest {
@Size(min = 2, max = 100, message = "Company name must be between 2 and 100 characters")
private String name;
@Size(max = 500, message = "Description must not exceed 500 characters")
private String description;
@Size(max = 255, message = "Website must not exceed 255 characters")
private String website;
@Size(max = 100, message = "Industry must not exceed 100 characters")
private String industry;
}

View File

@ -0,0 +1,33 @@
package com.dh7789dev.xpeditis.dto.request;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.experimental.FieldDefaults;
@Data
@Builder(toBuilder = true)
@AllArgsConstructor
@NoArgsConstructor
@FieldDefaults(level = AccessLevel.PRIVATE)
public class UpdateProfileRequest {
@Size(max = 50, message = "First name must not exceed 50 characters")
String firstName;
@Size(max = 50, message = "Last name must not exceed 50 characters")
String lastName;
@Pattern(
regexp = "^\\+?[1-9]\\d{1,14}$",
message = "Invalid phone number format"
)
String phoneNumber;
@Size(max = 50, message = "Username must not exceed 50 characters")
String username;
}

View File

@ -4,6 +4,7 @@ import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.experimental.Accessors;
@ -12,6 +13,7 @@ import lombok.experimental.FieldDefaults;
import java.util.Date;
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
@FieldDefaults(level = AccessLevel.PRIVATE)
@ -25,12 +27,22 @@ public class AuthenticationResponse {
@JsonProperty("refresh_token")
String refreshToken;
@JsonProperty("token_type")
@Builder.Default
String tokenType = "Bearer";
@JsonProperty("expires_in")
long expiresIn;
@JsonProperty("created_at")
Date createdAt;
@JsonProperty("expires_at")
Date expiresAt;
@JsonProperty("user")
UserResponse user;
@JsonProperty("error_message")
String error;
}

View File

@ -0,0 +1,23 @@
package com.dh7789dev.xpeditis.dto.response;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.experimental.FieldDefaults;
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
@FieldDefaults(level = AccessLevel.PRIVATE)
public class TokenResponse {
String token;
String refreshToken;
long expiresIn;
@Builder.Default
String tokenType = "Bearer";
UserResponse user;
}

View File

@ -0,0 +1,41 @@
package com.dh7789dev.xpeditis.dto.response;
import com.dh7789dev.xpeditis.dto.app.AuthProvider;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.experimental.FieldDefaults;
import java.time.LocalDateTime;
import java.util.UUID;
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
@FieldDefaults(level = AccessLevel.PRIVATE)
public class UserResponse {
UUID id;
String firstName;
String lastName;
String email;
String username;
String phoneNumber;
AuthProvider authProvider;
boolean privacyPolicyAccepted;
LocalDateTime privacyPolicyAcceptedAt;
LocalDateTime createdAt;
LocalDateTime lastLoginAt;
boolean isActive;
String role;
String companyName;
UUID companyId;
public String getFullName() {
return (firstName != null ? firstName : "") +
(lastName != null ? " " + lastName : "").trim();
}
}

View File

@ -0,0 +1,29 @@
package com.dh7789dev.xpeditis.dto.valueobject;
import lombok.Value;
import java.util.regex.Pattern;
@Value
public class Email {
private static final Pattern EMAIL_PATTERN = Pattern.compile(
"^[A-Za-z0-9+_.-]+@([A-Za-z0-9.-]+\\.[A-Za-z]{2,})$"
);
String value;
public Email(String value) {
if (value == null || value.trim().isEmpty()) {
throw new IllegalArgumentException("Email cannot be null or empty");
}
if (!EMAIL_PATTERN.matcher(value.trim()).matches()) {
throw new IllegalArgumentException("Invalid email format: " + value);
}
this.value = value.trim().toLowerCase();
}
@Override
public String toString() {
return value;
}
}

View File

@ -0,0 +1,168 @@
package com.dh7789dev.xpeditis.dto.valueobject;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
import java.util.Objects;
/**
* Value Object représentant un montant monétaire avec devise
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Money {
private BigDecimal amount;
private String currency;
/**
* Crée un montant en euros
*/
public static Money euros(BigDecimal amount) {
return new Money(amount, "EUR");
}
/**
* Crée un montant en euros à partir d'un double
*/
public static Money euros(double amount) {
return euros(BigDecimal.valueOf(amount));
}
/**
* Crée un montant en dollars
*/
public static Money dollars(BigDecimal amount) {
return new Money(amount, "USD");
}
/**
* Crée un montant en dollars à partir d'un double
*/
public static Money dollars(double amount) {
return dollars(BigDecimal.valueOf(amount));
}
/**
* Crée un montant zéro dans la devise spécifiée
*/
public static Money zero(String currency) {
return new Money(BigDecimal.ZERO, currency);
}
/**
* Crée un montant zéro en euros
*/
public static Money zeroEuros() {
return zero("EUR");
}
/**
* @return true si le montant est positif
*/
public boolean isPositive() {
return amount != null && amount.compareTo(BigDecimal.ZERO) > 0;
}
/**
* @return true si le montant est zéro
*/
public boolean isZero() {
return amount == null || amount.compareTo(BigDecimal.ZERO) == 0;
}
/**
* @return true si le montant est négatif
*/
public boolean isNegative() {
return amount != null && amount.compareTo(BigDecimal.ZERO) < 0;
}
/**
* Additionne deux montants (doivent avoir la même devise)
*/
public Money add(Money other) {
if (!Objects.equals(this.currency, other.currency)) {
throw new IllegalArgumentException("Cannot add amounts with different currencies: "
+ this.currency + " and " + other.currency);
}
return new Money(this.amount.add(other.amount), this.currency);
}
/**
* Soustrait un montant (doivent avoir la même devise)
*/
public Money subtract(Money other) {
if (!Objects.equals(this.currency, other.currency)) {
throw new IllegalArgumentException("Cannot subtract amounts with different currencies: "
+ this.currency + " and " + other.currency);
}
return new Money(this.amount.subtract(other.amount), this.currency);
}
/**
* Multiplie par un facteur
*/
public Money multiply(BigDecimal factor) {
return new Money(this.amount.multiply(factor), this.currency);
}
/**
* Multiplie par un facteur double
*/
public Money multiply(double factor) {
return multiply(BigDecimal.valueOf(factor));
}
/**
* Divise par un diviseur
*/
public Money divide(BigDecimal divisor) {
return new Money(this.amount.divide(divisor, 2, java.math.RoundingMode.HALF_UP), this.currency);
}
/**
* Divise par un diviseur double
*/
public Money divide(double divisor) {
return divide(BigDecimal.valueOf(divisor));
}
/**
* @return le montant formaté avec la devise
*/
public String toDisplayString() {
if (amount == null) return "0.00 " + (currency != null ? currency : "");
switch (currency) {
case "EUR":
return String.format("%.2f €", amount);
case "USD":
return String.format("$%.2f", amount);
default:
return String.format("%.2f %s", amount, currency);
}
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Money money = (Money) o;
return Objects.equals(amount, money.amount) && Objects.equals(currency, money.currency);
}
@Override
public int hashCode() {
return Objects.hash(amount, currency);
}
@Override
public String toString() {
return toDisplayString();
}
}

View File

@ -0,0 +1,33 @@
package com.dh7789dev.xpeditis.dto.valueobject;
import lombok.Value;
import java.util.regex.Pattern;
@Value
public class PhoneNumber {
private static final Pattern PHONE_PATTERN = Pattern.compile(
"^\\+?[1-9]\\d{1,14}$"
);
String value;
public PhoneNumber(String value) {
if (value == null || value.trim().isEmpty()) {
throw new IllegalArgumentException("Phone number cannot be null or empty");
}
String cleanedPhone = value.replaceAll("[\\s\\-\\(\\)]", "");
if (!PHONE_PATTERN.matcher(cleanedPhone).matches()) {
throw new IllegalArgumentException("Invalid phone number format: " + value);
}
this.value = cleanedPhone;
}
@Override
public String toString() {
return value;
}
}

View File

@ -0,0 +1,12 @@
package com.dh7789dev.xpeditis.exception;
public class AuthenticationException extends RuntimeException {
public AuthenticationException(String message) {
super(message);
}
public AuthenticationException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@ -0,0 +1,12 @@
package com.dh7789dev.xpeditis.exception;
public class BusinessException extends RuntimeException {
public BusinessException(String message) {
super(message);
}
public BusinessException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@ -0,0 +1,11 @@
package com.dh7789dev.xpeditis.exception;
public class CompanyInactiveException extends RuntimeException {
public CompanyInactiveException(String message) {
super(message);
}
public CompanyInactiveException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@ -0,0 +1,11 @@
package com.dh7789dev.xpeditis.exception;
public class InvalidCredentialsException extends RuntimeException {
public InvalidCredentialsException(String message) {
super(message);
}
public InvalidCredentialsException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@ -0,0 +1,11 @@
package com.dh7789dev.xpeditis.exception;
public class LicenseExpiredException extends RuntimeException {
public LicenseExpiredException(String message) {
super(message);
}
public LicenseExpiredException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@ -0,0 +1,11 @@
package com.dh7789dev.xpeditis.exception;
public class LicenseUserLimitExceededException extends RuntimeException {
public LicenseUserLimitExceededException(String message) {
super(message);
}
public LicenseUserLimitExceededException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@ -0,0 +1,12 @@
package com.dh7789dev.xpeditis.exception;
public class ResourceNotFoundException extends RuntimeException {
public ResourceNotFoundException(String message) {
super(message);
}
public ResourceNotFoundException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@ -0,0 +1,11 @@
package com.dh7789dev.xpeditis.exception;
public class UserAlreadyExistsException extends RuntimeException {
public UserAlreadyExistsException(String message) {
super(message);
}
public UserAlreadyExistsException(String message, Throwable cause) {
super(message, cause);
}
}

Some files were not shown because too many files have changed in this diff Show More