feature test license and stripe abonnement

This commit is contained in:
David 2025-09-16 16:41:51 +02:00
parent b3ed387197
commit da8da492d2
53 changed files with 8312 additions and 24 deletions

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(),
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);
Page<Subscription> subscriptions = subscriptionService.findSubscriptions(
status, billingCycle, customerId, pageable);
List<SubscriptionDto.Summary> dtos = subscriptions.getContent().stream()
.map(dtoMapper::toSummaryDto)
.collect(Collectors.toList());
Page<SubscriptionDto.Summary> result = new PageImpl<>(dtos, pageable, subscriptions.getTotalElements());
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());
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

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

@ -0,0 +1,71 @@
package com.dh7789dev.xpeditis;
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 com.dh7789dev.xpeditis.port.in.SubscriptionManagementUseCase;
import java.util.List;
import java.util.UUID;
/**
* Service de gestion des abonnements avec intégration Stripe
*/
public interface SubscriptionService extends SubscriptionManagementUseCase {
/**
* Crée un abonnement Stripe et la licence associée
*/
Subscription createSubscription(UUID companyId, Plan plan, BillingCycle billingCycle);
/**
* Met à jour un abonnement depuis un webhook Stripe
*/
Subscription updateSubscriptionFromStripe(String stripeSubscriptionId, SubscriptionStatus newStatus);
/**
* Change le plan d'un abonnement
*/
Subscription changePlan(UUID companyId, Plan newPlan, boolean immediate);
/**
* Annule un abonnement
*/
Subscription cancelSubscription(UUID companyId, boolean immediate, String reason);
/**
* Réactive un abonnement annulé
*/
Subscription reactivateSubscription(UUID companyId);
/**
* Récupère l'abonnement actif d'une entreprise
*/
Subscription getActiveSubscription(UUID companyId);
/**
* Traite un échec de paiement
*/
void handlePaymentFailure(String stripeSubscriptionId, String reason);
/**
* Traite un paiement réussi
*/
void handlePaymentSuccess(String stripeSubscriptionId, String stripeInvoiceId);
/**
* Récupère les abonnements nécessitant une attention
*/
List<Subscription> getSubscriptionsRequiringAttention();
/**
* 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,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,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

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

@ -7,6 +7,7 @@ import lombok.NoArgsConstructor;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.Set;
import java.util.UUID; import java.util.UUID;
@Data @Data
@ -17,38 +18,184 @@ public class License {
private UUID id; private UUID id;
private String licenseKey; private String licenseKey;
private LicenseType type; private LicenseType type;
private LicenseStatus status;
private LocalDate startDate; private LocalDate startDate;
private LocalDate expirationDate; private LocalDate expirationDate;
private LocalDateTime issuedDate; private LocalDateTime issuedDate;
private LocalDateTime expiryDate; private LocalDateTime expiryDate;
private LocalDateTime gracePeriodEndDate;
private int maxUsers; private int maxUsers;
private Set<String> featuresEnabled;
private boolean isActive; private boolean isActive;
private LocalDateTime createdAt; private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private LocalDateTime lastCheckedAt;
private Company company; private Company company;
private Subscription subscription;
/**
* @return true si la licence est expirée (date d'expiration dépassée)
*/
public boolean isExpired() { public boolean isExpired() {
return expirationDate != null && expirationDate.isBefore(LocalDate.now()); return expirationDate != null && expirationDate.isBefore(LocalDate.now());
} }
public LocalDateTime getExpiryDate() { public LocalDateTime getExpiryDate() {
return expiryDate; 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() { public boolean isValid() {
return isActive && !isExpired(); 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) { public boolean canAddUser(int currentUserCount) {
return !hasUserLimit() || currentUserCount < maxUsers; return isValid() && (!hasUserLimit() || currentUserCount < maxUsers);
} }
/**
* @return true si ce type de licence a une limite d'utilisateurs
*/
public boolean hasUserLimit() { public boolean hasUserLimit() {
return type != null && type.hasUserLimit(); return type != null && type.hasUserLimit();
} }
/**
* @return le nombre de jours jusqu'à l'expiration
*/
public long getDaysUntilExpiration() { public long getDaysUntilExpiration() {
return expirationDate != null ? return expirationDate != null ?
java.time.temporal.ChronoUnit.DAYS.between(LocalDate.now(), expirationDate) : java.time.temporal.ChronoUnit.DAYS.between(LocalDate.now(), expirationDate) :
Long.MAX_VALUE; 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

@ -1,17 +1,25 @@
package com.dh7789dev.xpeditis.dto.app; package com.dh7789dev.xpeditis.dto.app;
import java.math.BigDecimal;
public enum LicenseType { public enum LicenseType {
TRIAL(5, 30), TRIAL(5, 30, "Trial", BigDecimal.ZERO, BigDecimal.ZERO),
BASIC(10, -1), BASIC(10, -1, "Basic Plan", BigDecimal.valueOf(29.00), BigDecimal.valueOf(278.00)),
PREMIUM(50, -1), PREMIUM(50, -1, "Premium Plan", BigDecimal.valueOf(79.00), BigDecimal.valueOf(758.00)),
ENTERPRISE(-1, -1); ENTERPRISE(-1, -1, "Enterprise Plan", BigDecimal.valueOf(199.00), BigDecimal.valueOf(1908.00));
private final int maxUsers; private final int maxUsers;
private final int durationDays; private final int durationDays;
private final String description;
private final BigDecimal basePrice;
private final BigDecimal yearlyPrice;
LicenseType(int maxUsers, int durationDays) { LicenseType(int maxUsers, int durationDays, String description, BigDecimal basePrice, BigDecimal yearlyPrice) {
this.maxUsers = maxUsers; this.maxUsers = maxUsers;
this.durationDays = durationDays; this.durationDays = durationDays;
this.description = description;
this.basePrice = basePrice;
this.yearlyPrice = yearlyPrice;
} }
public int getMaxUsers() { public int getMaxUsers() {
@ -29,4 +37,44 @@ public enum LicenseType {
public boolean hasTimeLimit() { public boolean hasTimeLimit() {
return durationDays > 0; 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,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,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,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

@ -3,8 +3,14 @@ package com.dh7789dev.xpeditis;
import com.dh7789dev.xpeditis.dto.app.Company; import com.dh7789dev.xpeditis.dto.app.Company;
import com.dh7789dev.xpeditis.dto.app.License; import com.dh7789dev.xpeditis.dto.app.License;
import com.dh7789dev.xpeditis.dto.app.LicenseType; import com.dh7789dev.xpeditis.dto.app.LicenseType;
import com.dh7789dev.xpeditis.dto.app.LicenseStatus;
import com.dh7789dev.xpeditis.dto.app.PlanFeature;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.Set;
import java.util.UUID; import java.util.UUID;
@Service @Service
@ -18,43 +24,176 @@ public class LicenseServiceImpl implements LicenseService {
@Override @Override
public boolean validateLicense(UUID companyId) { public boolean validateLicense(UUID companyId) {
// TODO: Implement license validation logic License license = getActiveLicense(companyId);
return true; // Temporary implementation if (license == null) {
return false;
}
// Mettre à jour la dernière vérification
license.updateLastChecked();
licenseRepository.save(license);
// Vérifier si la licence est valide
return license.isValid();
} }
@Override @Override
public boolean canAddUser(UUID companyId) { public boolean canAddUser(UUID companyId) {
// TODO: Implement user addition validation logic License license = getActiveLicense(companyId);
return true; // Temporary implementation if (license == null || !license.isValid()) {
return false;
}
// TODO: Récupérer le nombre d'utilisateurs actuel (nécessite UserService)
int currentUserCount = 0; // Placeholder - à implémenter avec injection UserService
return license.canAddUser(currentUserCount);
} }
@Override @Override
@Transactional
public License createTrialLicense(Company company) { public License createTrialLicense(Company company) {
// TODO: Implement trial license creation logic // Vérifier si l'entreprise a déjà une licence active
throw new UnsupportedOperationException("Not implemented yet"); License existingLicense = licenseRepository.findActiveLicenseByCompanyId(company.getId()).orElse(null);
if (existingLicense != null && existingLicense.isValid()) {
throw new IllegalStateException("L'entreprise a déjà une licence active");
}
// Créer les fonctionnalités de base pour le trial
Set<String> trialFeatures = Set.of(
PlanFeature.Features.BASIC_QUOTES,
PlanFeature.Features.DOCUMENT_TEMPLATES
);
// Créer la licence trial
License trialLicense = License.builder()
.id(UUID.randomUUID())
.licenseKey(generateLicenseKey("TRIAL"))
.type(LicenseType.TRIAL)
.status(LicenseStatus.ACTIVE)
.startDate(LocalDate.now())
.expirationDate(LocalDate.now().plusDays(LicenseType.TRIAL.getDurationDays()))
.maxUsers(LicenseType.TRIAL.getMaxUsers())
.featuresEnabled(trialFeatures)
.isActive(true)
.company(company)
.createdAt(LocalDateTime.now())
.updatedAt(LocalDateTime.now())
.build();
return licenseRepository.save(trialLicense);
} }
@Override @Override
@Transactional
public License upgradeLicense(UUID companyId, LicenseType newType) { public License upgradeLicense(UUID companyId, LicenseType newType) {
// TODO: Implement license upgrade logic License currentLicense = getActiveLicense(companyId);
throw new UnsupportedOperationException("Not implemented yet"); if (currentLicense == null) {
throw new IllegalArgumentException("Aucune licence active trouvée pour cette entreprise");
}
// Vérifier que c'est bien un upgrade
if (newType == currentLicense.getType()) {
throw new IllegalArgumentException("La licence est déjà de ce type");
}
// Créer les nouvelles fonctionnalités selon le type
Set<String> newFeatures = getFeaturesForLicenseType(newType);
// Mettre à jour la licence existante
currentLicense.setType(newType);
currentLicense.setMaxUsers(newType.getMaxUsers());
currentLicense.setFeaturesEnabled(newFeatures);
currentLicense.setUpdatedAt(LocalDateTime.now());
// Pour un upgrade vers un plan payant, on supprime la date d'expiration
if (newType != LicenseType.TRIAL) {
currentLicense.setExpirationDate(null);
}
return licenseRepository.save(currentLicense);
} }
@Override @Override
@Transactional
public void deactivateLicense(UUID licenseId) { public void deactivateLicense(UUID licenseId) {
// TODO: Implement license deactivation logic License license = licenseRepository.findById(licenseId)
throw new UnsupportedOperationException("Not implemented yet"); .orElseThrow(() -> new IllegalArgumentException("Licence non trouvée"));
license.suspend();
licenseRepository.save(license);
} }
@Override @Override
public License getActiveLicense(UUID companyId) { public License getActiveLicense(UUID companyId) {
// TODO: Implement active license retrieval logic return licenseRepository.findActiveLicenseByCompanyId(companyId)
throw new UnsupportedOperationException("Not implemented yet"); .orElse(null);
} }
@Override @Override
public long getDaysUntilExpiration(UUID companyId) { public long getDaysUntilExpiration(UUID companyId) {
// TODO: Implement days until expiration calculation logic License license = getActiveLicense(companyId);
return Long.MAX_VALUE; // Temporary implementation if (license == null) {
return 0;
}
return license.getDaysUntilExpiration();
}
/**
* Génère une clé de licence unique
*/
private String generateLicenseKey(String prefix) {
return prefix + "-" + UUID.randomUUID().toString().substring(0, 8).toUpperCase();
}
/**
* Retourne les fonctionnalités pour un type de licence donné
*/
private Set<String> getFeaturesForLicenseType(LicenseType type) {
switch (type) {
case TRIAL:
return Set.of(
PlanFeature.Features.BASIC_QUOTES,
PlanFeature.Features.DOCUMENT_TEMPLATES
);
case BASIC:
return Set.of(
PlanFeature.Features.BASIC_QUOTES,
PlanFeature.Features.DOCUMENT_TEMPLATES,
PlanFeature.Features.MULTI_CURRENCY,
PlanFeature.Features.TEAM_COLLABORATION
);
case PREMIUM:
return Set.of(
PlanFeature.Features.BASIC_QUOTES,
PlanFeature.Features.DOCUMENT_TEMPLATES,
PlanFeature.Features.MULTI_CURRENCY,
PlanFeature.Features.TEAM_COLLABORATION,
PlanFeature.Features.ADVANCED_ANALYTICS,
PlanFeature.Features.BULK_IMPORT,
PlanFeature.Features.API_ACCESS,
PlanFeature.Features.PRIORITY_SUPPORT,
PlanFeature.Features.AUTOMATED_WORKFLOWS
);
case ENTERPRISE:
return Set.of(
PlanFeature.Features.BASIC_QUOTES,
PlanFeature.Features.DOCUMENT_TEMPLATES,
PlanFeature.Features.MULTI_CURRENCY,
PlanFeature.Features.TEAM_COLLABORATION,
PlanFeature.Features.ADVANCED_ANALYTICS,
PlanFeature.Features.BULK_IMPORT,
PlanFeature.Features.API_ACCESS,
PlanFeature.Features.PRIORITY_SUPPORT,
PlanFeature.Features.AUTOMATED_WORKFLOWS,
PlanFeature.Features.CUSTOM_BRANDING,
PlanFeature.Features.CUSTOM_INTEGRATIONS,
PlanFeature.Features.AUDIT_LOGS,
PlanFeature.Features.SLA_GUARANTEE,
PlanFeature.Features.DEDICATED_SUPPORT
);
default:
return Set.of();
}
} }
} }

View File

@ -0,0 +1,171 @@
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.out.PlanRepository;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import java.util.ArrayList;
import java.util.stream.Collectors;
/**
* Implémentation du service de gestion des plans
*/
@Service
public class PlanServiceImpl implements PlanService {
private final PlanRepository planRepository;
public PlanServiceImpl(PlanRepository planRepository) {
this.planRepository = planRepository;
}
@Override
public List<Plan> getAllActivePlans() {
return planRepository.findAllByIsActiveTrue();
}
@Override
public Plan getPlanById(UUID planId) {
return planRepository.findById(planId)
.orElseThrow(() -> new IllegalArgumentException("Plan non trouvé: " + planId));
}
@Override
public Plan getPlanByType(LicenseType type) {
return planRepository.findByTypeAndIsActiveTrue(type)
.orElseThrow(() -> new IllegalArgumentException("Plan non trouvé pour le type: " + type));
}
@Override
public Plan getPlanByStripePriceId(String stripePriceId) {
return planRepository.findByStripePriceId(stripePriceId)
.orElseThrow(() -> new IllegalArgumentException("Plan non trouvé pour l'ID Stripe: " + stripePriceId));
}
@Override
public Money calculateProrata(Plan currentPlan, Plan newPlan, BillingCycle cycle, int daysRemaining) {
if (currentPlan.equals(newPlan)) {
return Money.zeroEuros();
}
BigDecimal currentPrice = currentPlan.getPriceForCycle(cycle);
BigDecimal newPrice = newPlan.getPriceForCycle(cycle);
int totalDaysInCycle = cycle.getMonths() * 30; // Approximation
// Calcul du crédit pour la période restante du plan actuel
BigDecimal dailyCurrentRate = currentPrice.divide(BigDecimal.valueOf(totalDaysInCycle), 4, RoundingMode.HALF_UP);
BigDecimal credit = dailyCurrentRate.multiply(BigDecimal.valueOf(daysRemaining));
// Calcul du coût pour la période restante du nouveau plan
BigDecimal dailyNewRate = newPrice.divide(BigDecimal.valueOf(totalDaysInCycle), 4, RoundingMode.HALF_UP);
BigDecimal newCost = dailyNewRate.multiply(BigDecimal.valueOf(daysRemaining));
// Montant à payer = nouveau coût - crédit
BigDecimal amountDue = newCost.subtract(credit);
return Money.euros(amountDue);
}
@Override
public List<Plan> findSuitablePlansForUserCount(int userCount) {
return planRepository.findSuitableForUserCount(userCount);
}
@Override
public PlanComparison comparePlans(Plan plan1, Plan plan2) {
Set<String> features1 = plan1.getFeatures() != null ? plan1.getFeatures() : Set.of();
Set<String> features2 = plan2.getFeatures() != null ? plan2.getFeatures() : Set.of();
// Fonctionnalités ajoutées (dans plan2 mais pas dans plan1)
List<String> addedFeatures = features2.stream()
.filter(feature -> !features1.contains(feature))
.collect(Collectors.toList());
// Fonctionnalités supprimées (dans plan1 mais pas dans plan2)
List<String> removedFeatures = features1.stream()
.filter(feature -> !features2.contains(feature))
.collect(Collectors.toList());
// Différence de prix (mensuel)
BigDecimal priceDiff = plan2.getMonthlyPrice().subtract(plan1.getMonthlyPrice());
Money priceDifference = Money.euros(priceDiff);
// Déterminer si c'est un upgrade
boolean isUpgrade = isUpgrade(plan1, plan2);
return new PlanComparison(plan1, plan2, addedFeatures, removedFeatures, priceDifference, isUpgrade);
}
@Override
public boolean canChangePlan(Plan currentPlan, Plan targetPlan, int currentUserCount) {
// Vérifier si le plan cible peut supporter le nombre d'utilisateurs actuel
if (targetPlan.hasUserLimit() && targetPlan.getMaxUsers() != null
&& currentUserCount > targetPlan.getMaxUsers()) {
return false;
}
// Vérifier si c'est un downgrade valide (pas de perte de fonctionnalités critiques utilisées)
if (!isUpgrade(currentPlan, targetPlan)) {
// Pour un downgrade, on pourrait vérifier l'utilisation des fonctionnalités
// Ici on autorise tous les downgrades pour simplifier
return true;
}
return true;
}
@Override
public Plan getRecommendedPlan(int userCount, List<String> requiredFeatures) {
List<Plan> allPlans = getAllActivePlans();
return allPlans.stream()
.filter(plan -> {
// Le plan doit supporter le nombre d'utilisateurs
if (plan.hasUserLimit() && plan.getMaxUsers() != null && userCount > plan.getMaxUsers()) {
return false;
}
// Le plan doit inclure toutes les fonctionnalités requises
Set<String> planFeatures = plan.getFeatures() != null ? plan.getFeatures() : Set.of();
return planFeatures.containsAll(requiredFeatures);
})
.min((p1, p2) -> {
// Prendre le plan le moins cher qui satisfait les critères
return p1.getMonthlyPrice().compareTo(p2.getMonthlyPrice());
})
.orElse(null);
}
/**
* Détermine si le changement de plan1 vers plan2 est un upgrade
*/
private boolean isUpgrade(Plan plan1, Plan plan2) {
// Basé sur l'ordre des types de licence
int order1 = getLicenseTypeOrder(plan1.getType());
int order2 = getLicenseTypeOrder(plan2.getType());
return order2 > order1;
}
/**
* Retourne l'ordre du type de licence pour déterminer les upgrades
*/
private int getLicenseTypeOrder(LicenseType type) {
switch (type) {
case TRIAL: return 0;
case BASIC: return 1;
case PREMIUM: return 2;
case ENTERPRISE: return 3;
default: return -1;
}
}
}

View File

@ -0,0 +1,263 @@
package com.dh7789dev.xpeditis;
import com.dh7789dev.xpeditis.dto.app.*;
import com.dh7789dev.xpeditis.port.out.SubscriptionRepository;
import com.dh7789dev.xpeditis.port.out.InvoiceRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
/**
* Implémentation du service de gestion des abonnements
*/
@Service
public class SubscriptionServiceImpl implements SubscriptionService {
private final SubscriptionRepository subscriptionRepository;
private final InvoiceRepository invoiceRepository;
private final LicenseService licenseService;
private static final int GRACE_PERIOD_DAYS = 7;
public SubscriptionServiceImpl(
SubscriptionRepository subscriptionRepository,
InvoiceRepository invoiceRepository,
LicenseService licenseService
) {
this.subscriptionRepository = subscriptionRepository;
this.invoiceRepository = invoiceRepository;
this.licenseService = licenseService;
}
@Override
@Transactional
public Subscription createSubscription(UUID companyId, Plan plan, BillingCycle billingCycle) {
// Vérifier qu'il n'y a pas déjà un abonnement actif
Subscription existingSubscription = getActiveSubscription(companyId);
if (existingSubscription != null) {
throw new IllegalStateException("Une entreprise ne peut avoir qu'un seul abonnement actif");
}
// Créer l'abonnement
Subscription subscription = Subscription.builder()
.id(UUID.randomUUID())
.status(SubscriptionStatus.INCOMPLETE) // En attente de la création Stripe
.billingCycle(billingCycle)
.currentPeriodStart(LocalDateTime.now())
.currentPeriodEnd(LocalDateTime.now().plusDays(billingCycle.getMonths() * 30L))
.cancelAtPeriodEnd(false)
.createdAt(LocalDateTime.now())
.updatedAt(LocalDateTime.now())
.build();
// Calculer la prochaine date de facturation
subscription.setNextBillingDate(subscription.getCurrentPeriodEnd());
return subscriptionRepository.save(subscription);
}
@Override
@Transactional
public Subscription updateSubscriptionFromStripe(String stripeSubscriptionId, SubscriptionStatus newStatus) {
Subscription subscription = subscriptionRepository.findByStripeSubscriptionId(stripeSubscriptionId)
.orElseThrow(() -> new IllegalArgumentException("Abonnement Stripe non trouvé: " + stripeSubscriptionId));
SubscriptionStatus oldStatus = subscription.getStatus();
subscription.updateStatus(newStatus);
// Gérer les transitions d'état
handleStatusTransition(subscription, oldStatus, newStatus);
return subscriptionRepository.save(subscription);
}
@Override
@Transactional
public Subscription changePlan(UUID companyId, Plan newPlan, boolean immediate) {
Subscription subscription = getActiveSubscription(companyId);
if (subscription == null) {
throw new IllegalArgumentException("Aucun abonnement actif trouvé pour cette entreprise");
}
// Mettre à jour l'abonnement
subscription.setStripePriceId(newPlan.getStripePriceIdForCycle(subscription.getBillingCycle()));
subscription.setUpdatedAt(LocalDateTime.now());
// Si c'est immédiat, mettre à jour la licence aussi
if (immediate && subscription.getLicense() != null) {
licenseService.upgradeLicense(companyId, newPlan.getType());
}
return subscriptionRepository.save(subscription);
}
@Override
@Transactional
public Subscription cancelSubscription(UUID companyId, boolean immediate, String reason) {
Subscription subscription = getActiveSubscription(companyId);
if (subscription == null) {
throw new IllegalArgumentException("Aucun abonnement actif trouvé pour cette entreprise");
}
if (immediate) {
subscription.updateStatus(SubscriptionStatus.CANCELED);
// Suspendre la licence immédiatement
if (subscription.getLicense() != null) {
licenseService.deactivateLicense(subscription.getLicense().getId());
}
} else {
// Programmer l'annulation à la fin de la période
subscription.scheduleForCancellation();
}
return subscriptionRepository.save(subscription);
}
@Override
@Transactional
public Subscription reactivateSubscription(UUID companyId) {
// Chercher un abonnement annulé ou suspendu
List<Subscription> subscriptions = subscriptionRepository.findByCompanyId(companyId);
Subscription subscription = subscriptions.stream()
.filter(s -> s.canBeReactivated())
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("Aucun abonnement réactivable trouvé"));
subscription.updateStatus(SubscriptionStatus.ACTIVE);
subscription.unscheduleForCancellation();
// Réactiver la licence
if (subscription.getLicense() != null) {
subscription.getLicense().reactivate();
}
return subscriptionRepository.save(subscription);
}
@Override
public Subscription getActiveSubscription(UUID companyId) {
return subscriptionRepository.findActiveByCompanyId(companyId)
.orElse(null);
}
@Override
@Transactional
public void handlePaymentFailure(String stripeSubscriptionId, String reason) {
Subscription subscription = subscriptionRepository.findByStripeSubscriptionId(stripeSubscriptionId)
.orElseThrow(() -> new IllegalArgumentException("Abonnement non trouvé"));
// Mettre en statut PAST_DUE
subscription.updateStatus(SubscriptionStatus.PAST_DUE);
// Démarrer la période de grâce pour la licence
if (subscription.getLicense() != null) {
subscription.getLicense().startGracePeriod(GRACE_PERIOD_DAYS);
}
subscriptionRepository.save(subscription);
// TODO: Envoyer notification d'échec de paiement
}
@Override
@Transactional
public void handlePaymentSuccess(String stripeSubscriptionId, String stripeInvoiceId) {
Subscription subscription = subscriptionRepository.findByStripeSubscriptionId(stripeSubscriptionId)
.orElseThrow(() -> new IllegalArgumentException("Abonnement non trouvé"));
// Remettre en statut actif si nécessaire
if (subscription.getStatus() == SubscriptionStatus.PAST_DUE) {
subscription.updateStatus(SubscriptionStatus.ACTIVE);
// Réactiver la licence
if (subscription.getLicense() != null) {
subscription.getLicense().reactivate();
}
}
// Mettre à jour les dates de période
LocalDateTime now = LocalDateTime.now();
subscription.setCurrentPeriodStart(now);
subscription.setCurrentPeriodEnd(now.plusDays(subscription.getBillingCycle().getMonths() * 30L));
subscription.setNextBillingDate(subscription.getCurrentPeriodEnd());
subscriptionRepository.save(subscription);
// TODO: Envoyer notification de paiement réussi
}
@Override
public List<Subscription> getSubscriptionsRequiringAttention() {
List<Subscription> pastDue = subscriptionRepository.findByStatus(SubscriptionStatus.PAST_DUE);
List<Subscription> unpaid = subscriptionRepository.findByStatus(SubscriptionStatus.UNPAID);
List<Subscription> incomplete = subscriptionRepository.findByStatus(SubscriptionStatus.INCOMPLETE);
pastDue.addAll(unpaid);
pastDue.addAll(incomplete);
return pastDue;
}
@Override
@Transactional
public void startGracePeriod(UUID subscriptionId) {
Subscription subscription = subscriptionRepository.findById(subscriptionId)
.orElseThrow(() -> new IllegalArgumentException("Abonnement non trouvé"));
subscription.updateStatus(SubscriptionStatus.PAST_DUE);
if (subscription.getLicense() != null) {
subscription.getLicense().startGracePeriod(GRACE_PERIOD_DAYS);
}
subscriptionRepository.save(subscription);
}
@Override
@Transactional
public void suspendUnpaidSubscriptions() {
// Récupérer les abonnements en période de grâce expirée
List<Subscription> gracePeriodExpired = subscriptionRepository.findInGracePeriod().stream()
.filter(subscription -> subscription.getLicense() != null
&& subscription.getLicense().getDaysRemainingInGracePeriod() <= 0)
.toList();
for (Subscription subscription : gracePeriodExpired) {
subscription.updateStatus(SubscriptionStatus.UNPAID);
if (subscription.getLicense() != null) {
subscription.getLicense().suspend();
}
subscriptionRepository.save(subscription);
}
}
/**
* Gère les transitions d'état des abonnements
*/
private void handleStatusTransition(Subscription subscription, SubscriptionStatus oldStatus, SubscriptionStatus newStatus) {
// Transition vers ACTIVE
if (newStatus == SubscriptionStatus.ACTIVE && oldStatus != SubscriptionStatus.ACTIVE) {
if (subscription.getLicense() != null) {
subscription.getLicense().reactivate();
}
}
// Transition vers CANCELED
if (newStatus == SubscriptionStatus.CANCELED) {
if (subscription.getLicense() != null) {
subscription.getLicense().suspend();
}
}
// Transition vers PAST_DUE
if (newStatus == SubscriptionStatus.PAST_DUE) {
if (subscription.getLicense() != null) {
subscription.getLicense().startGracePeriod(GRACE_PERIOD_DAYS);
}
}
}
}

View File

@ -57,6 +57,18 @@
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
<!-- Stripe SDK -->
<dependency>
<groupId>com.stripe</groupId>
<artifactId>stripe-java</artifactId>
</dependency>
<!-- Thymeleaf for email templates -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<!-- JWT --> <!-- JWT -->
<dependency> <dependency>
<groupId>io.jsonwebtoken</groupId> <groupId>io.jsonwebtoken</groupId>

View File

@ -0,0 +1,9 @@
package com.dh7789dev.xpeditis.entity;
/**
* Énumération des cycles de facturation
*/
public enum BillingCycleEntity {
MONTHLY, // Mensuel
YEARLY // Annuel
}

View File

@ -0,0 +1,245 @@
package com.dh7789dev.xpeditis.entity;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.experimental.FieldDefaults;
import lombok.experimental.FieldNameConstants;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
/**
* Entité JPA pour les factures Stripe
*/
@Entity
@Getter
@Setter
@NoArgsConstructor
@FieldNameConstants
@FieldDefaults(level = AccessLevel.PRIVATE)
@Table(name = "invoices")
@EntityListeners(org.springframework.data.jpa.domain.support.AuditingEntityListener.class)
public class InvoiceEntity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Column(columnDefinition = "BINARY(16)")
UUID id;
@Column(name = "stripe_invoice_id", unique = true, nullable = false)
String stripeInvoiceId;
@Column(name = "invoice_number", unique = true, nullable = false, length = 50)
String invoiceNumber;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "subscription_id", nullable = false, foreignKey = @ForeignKey(name = "fk_invoice_subscription"))
SubscriptionEntity subscription;
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 50)
InvoiceStatusEntity status;
@Column(name = "amount_due", nullable = false, precision = 10, scale = 2)
BigDecimal amountDue;
@Column(name = "amount_paid", precision = 10, scale = 2)
BigDecimal amountPaid = BigDecimal.ZERO;
@Column(name = "currency", nullable = false, length = 3)
String currency = "EUR";
@Column(name = "billing_period_start", nullable = false)
LocalDateTime billingPeriodStart;
@Column(name = "billing_period_end", nullable = false)
LocalDateTime billingPeriodEnd;
@Column(name = "due_date")
LocalDateTime dueDate;
@Column(name = "paid_at")
LocalDateTime paidAt;
@Column(name = "invoice_pdf_url", columnDefinition = "TEXT")
String invoicePdfUrl;
@Column(name = "hosted_invoice_url", columnDefinition = "TEXT")
String hostedInvoiceUrl;
@Column(name = "attempt_count", nullable = false)
Integer attemptCount = 0;
@OneToMany(mappedBy = "invoice", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
List<InvoiceLineItemEntity> lineItems;
@Column(name = "created_at", updatable = false)
LocalDateTime createdAt;
@org.springframework.data.annotation.CreatedDate
@Column(name = "created_date", updatable = false, nullable = false)
java.time.Instant createdDate;
@org.springframework.data.annotation.LastModifiedDate
@Column(name = "modified_date", nullable = false)
java.time.Instant modifiedDate;
@org.springframework.data.annotation.CreatedBy
@Column(name = "created_by", updatable = false, nullable = false)
String createdBy = "SYSTEM";
@org.springframework.data.annotation.LastModifiedBy
@Column(name = "modified_by")
String modifiedBy;
@PrePersist
public void onCreate() {
if (id == null) {
id = UUID.randomUUID();
}
if (createdAt == null) {
createdAt = LocalDateTime.now();
}
}
/**
* @return true si la facture est payée
*/
public boolean isPaid() {
return status == InvoiceStatusEntity.PAID;
}
/**
* @return true si la facture est en attente de paiement
*/
public boolean isPending() {
return status == InvoiceStatusEntity.OPEN;
}
/**
* @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 0;
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() {
return java.time.temporal.ChronoUnit.DAYS.between(billingPeriodStart, billingPeriodEnd);
}
/**
* @return true si cette facture nécessite une attention (retard, échecs répétés)
*/
public boolean requiresAttention() {
return isOverdue() ||
(attemptCount != null && attemptCount > 3) ||
status == InvoiceStatusEntity.PAYMENT_FAILED;
}
/**
* @return le montant total des lignes de facture
*/
public BigDecimal calculateTotalFromLineItems() {
if (lineItems == null || lineItems.isEmpty()) {
return BigDecimal.ZERO;
}
return lineItems.stream()
.map(InvoiceLineItemEntity::getAmount)
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
/**
* @return true si la facture inclut du prorata
*/
public boolean hasProrationItems() {
return lineItems != null &&
lineItems.stream().anyMatch(InvoiceLineItemEntity::getProrated);
}
/**
* Increment le nombre de tentatives de paiement
*/
public void incrementAttemptCount() {
if (attemptCount == null) {
attemptCount = 1;
} else {
attemptCount++;
}
}
/**
* Marque la facture comme payée
*/
public void markAsPaid(LocalDateTime paidDate) {
this.status = InvoiceStatusEntity.PAID;
this.paidAt = paidDate;
this.amountPaid = this.amountDue;
}
/**
* Marque la facture comme échouée
*/
public void markAsFailed() {
this.status = InvoiceStatusEntity.PAYMENT_FAILED;
incrementAttemptCount();
}
}
/**
* Énumération des statuts de facture
*/
enum InvoiceStatusEntity {
DRAFT, // Brouillon
OPEN, // En attente de paiement
PAID, // Payée
PAYMENT_FAILED, // Échec de paiement
VOIDED, // Annulée
UNCOLLECTIBLE // Irrécouvrable
}

View File

@ -0,0 +1,217 @@
package com.dh7789dev.xpeditis.entity;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.experimental.FieldDefaults;
import lombok.experimental.FieldNameConstants;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.UUID;
/**
* Entité JPA pour les lignes de facture
*/
@Entity
@Getter
@Setter
@NoArgsConstructor
@FieldNameConstants
@FieldDefaults(level = AccessLevel.PRIVATE)
@Table(name = "invoice_line_items")
@EntityListeners(org.springframework.data.jpa.domain.support.AuditingEntityListener.class)
public class InvoiceLineItemEntity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Column(columnDefinition = "BINARY(16)")
UUID id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "invoice_id", nullable = false, foreignKey = @ForeignKey(name = "fk_line_item_invoice"))
InvoiceEntity invoice;
@Column(name = "description", columnDefinition = "TEXT")
String description;
@Column(name = "quantity", nullable = false)
Integer quantity = 1;
@Column(name = "unit_price", precision = 10, scale = 2)
BigDecimal unitPrice;
@Column(name = "amount", nullable = false, precision = 10, scale = 2)
BigDecimal amount;
@Column(name = "stripe_price_id")
String stripePriceId;
@Column(name = "period_start")
LocalDateTime periodStart;
@Column(name = "period_end")
LocalDateTime periodEnd;
@Column(name = "prorated", nullable = false)
Boolean prorated = false;
@Column(name = "created_at", updatable = false)
LocalDateTime createdAt;
@org.springframework.data.annotation.CreatedDate
@Column(name = "created_date", updatable = false, nullable = false)
java.time.Instant createdDate;
@org.springframework.data.annotation.LastModifiedDate
@Column(name = "modified_date", nullable = false)
java.time.Instant modifiedDate;
@org.springframework.data.annotation.CreatedBy
@Column(name = "created_by", updatable = false, nullable = false)
String createdBy = "SYSTEM";
@org.springframework.data.annotation.LastModifiedBy
@Column(name = "modified_by")
String modifiedBy;
@PrePersist
public void onCreate() {
if (id == null) {
id = UUID.randomUUID();
}
if (createdAt == null) {
createdAt = LocalDateTime.now();
}
}
/**
* @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 0;
}
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();
// 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, UNKNOWN)
*/
public String getPeriodType() {
if (isProrationItem()) {
return "PRORATION";
}
long days = getPeriodDays();
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();
long expectedDays = getPeriodType().equals("YEAR") ? 365 : 30;
return (double) actualDays / 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();
}
/**
* Valide la cohérence des données de cette ligne
*/
public boolean isValid() {
// Vérifications de base
if (amount == null || amount.compareTo(BigDecimal.ZERO) < 0) {
return false;
}
if (quantity == null || quantity <= 0) {
return false;
}
// Si un prix unitaire est spécifié, vérifier la cohérence avec le montant
if (unitPrice != null) {
BigDecimal expectedAmount = unitPrice.multiply(BigDecimal.valueOf(quantity));
if (expectedAmount.compareTo(amount) != 0) {
return false;
}
}
// Si des dates de période sont spécifiées, vérifier leur cohérence
if (periodStart != null && periodEnd != null) {
if (periodStart.isAfter(periodEnd)) {
return false;
}
}
return true;
}
}

View File

@ -0,0 +1,306 @@
package com.dh7789dev.xpeditis.entity;
import com.dh7789dev.xpeditis.dto.app.EventStatus;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.experimental.FieldDefaults;
import lombok.experimental.FieldNameConstants;
import java.time.LocalDateTime;
import java.util.UUID;
/**
* Entité JPA pour les événements webhook Stripe
*/
@Entity
@Getter
@Setter
@NoArgsConstructor
@FieldNameConstants
@FieldDefaults(level = AccessLevel.PRIVATE)
@Table(name = "payment_events")
@EntityListeners(org.springframework.data.jpa.domain.support.AuditingEntityListener.class)
public class PaymentEventEntity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Column(columnDefinition = "BINARY(16)")
UUID id;
@Column(name = "stripe_event_id", unique = true, nullable = false)
String stripeEventId;
@Column(name = "event_type", nullable = false, length = 100)
String eventType;
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 50)
EventStatus status = EventStatus.PENDING;
@Column(name = "payload", nullable = false, columnDefinition = "JSON")
String payload;
@Column(name = "processed_at")
LocalDateTime processedAt;
@Column(name = "error_message", columnDefinition = "TEXT")
String errorMessage;
@Column(name = "retry_count", nullable = false)
Integer retryCount = 0;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "subscription_id", foreignKey = @ForeignKey(name = "fk_event_subscription"))
SubscriptionEntity subscription;
@Column(name = "created_at", updatable = false)
LocalDateTime createdAt;
@org.springframework.data.annotation.CreatedDate
@Column(name = "created_date", updatable = false, nullable = false)
java.time.Instant createdDate;
@org.springframework.data.annotation.LastModifiedDate
@Column(name = "modified_date", nullable = false)
java.time.Instant modifiedDate;
@org.springframework.data.annotation.CreatedBy
@Column(name = "created_by", updatable = false, nullable = false)
String createdBy = "SYSTEM";
@org.springframework.data.annotation.LastModifiedBy
@Column(name = "modified_by")
String modifiedBy;
@PrePersist
public void onCreate() {
if (id == null) {
id = UUID.randomUUID();
}
if (createdAt == null) {
createdAt = LocalDateTime.now();
}
}
/**
* @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 a été traité avec succès
*/
public boolean isProcessed() {
return status == EventStatus.PROCESSED;
}
/**
* @return true si le traitement de l'événement a échoué
*/
public boolean isFailed() {
return status == EventStatus.FAILED;
}
/**
* @return true si l'événement a été ignoré
*/
public boolean isIgnored() {
return status == EventStatus.IGNORED;
}
/**
* @return true si l'événement peut être retenté
*/
public boolean canBeRetried() {
return isFailed() && retryCount < getMaxRetryCount();
}
/**
* @return le nombre maximum de tentatives autorisées selon le type d'événement
*/
public int getMaxRetryCount() {
if (eventType == null) return 5;
return switch (eventType) {
case "invoice.payment_failed",
"invoice.payment_action_required" -> 10; // Plus de tentatives pour les paiements
case "customer.subscription.deleted",
"customer.subscription.updated" -> 3; // Moins critique
case "invoice.created",
"invoice.finalized" -> 5; // Standard
default -> 5; // Par défaut
};
}
/**
* @return true si l'événement est critique (nécessite un traitement rapide)
*/
public boolean isCritical() {
if (eventType == null) return false;
return eventType.startsWith("invoice.payment_failed") ||
eventType.startsWith("customer.subscription.deleted") ||
eventType.contains("failed") ||
eventType.contains("expired");
}
/**
* @return la priorité de traitement (1 = haute, 3 = basse)
*/
public int getPriority() {
if (isCritical()) return 1;
if (eventType != null) {
if (eventType.startsWith("invoice.") ||
eventType.startsWith("customer.subscription.")) {
return 2; // Priorité moyenne
}
}
return 3; // Priorité basse
}
/**
* @return true si cet événement est ancien et peut être archivé
*/
public boolean isOldEnoughToArchive() {
if (createdAt == null) return false;
LocalDateTime archiveThreshold = LocalDateTime.now().minusDays(30);
return createdAt.isBefore(archiveThreshold) && (isProcessed() || isIgnored());
}
/**
* @return true si l'événement est en cours de traitement depuis trop longtemps
*/
public boolean isProcessingTooLong() {
if (!isProcessing() || createdAt == null) return false;
LocalDateTime stuckThreshold = LocalDateTime.now().minusHours(1);
return createdAt.isBefore(stuckThreshold);
}
/**
* @return le temps de traitement en millisecondes (si traité)
*/
public Long getProcessingTimeMs() {
if (processedAt == null || createdAt == null) return null;
return java.time.Duration.between(createdAt, processedAt).toMillis();
}
/**
* @return true si l'événement nécessite une attention manuelle
*/
public boolean requiresAttention() {
return (isFailed() && retryCount >= getMaxRetryCount()) ||
isProcessingTooLong() ||
(isPending() && isOlderThan(24)); // En attente depuis plus de 24h
}
/**
* @return true si l'événement est plus ancien que le nombre d'heures spécifié
*/
public boolean isOlderThan(int hours) {
if (createdAt == null) return false;
LocalDateTime threshold = LocalDateTime.now().minusHours(hours);
return createdAt.isBefore(threshold);
}
/**
* @return le type d'objet Stripe concerné (customer, subscription, invoice, etc.)
*/
public String getStripeObjectType() {
if (eventType == null) return "unknown";
String[] parts = eventType.split("\\.");
return parts.length > 0 ? parts[0] : "unknown";
}
/**
* @return l'action de l'événement (created, updated, deleted, etc.)
*/
public String getEventAction() {
if (eventType == null) return "unknown";
String[] parts = eventType.split("\\.");
return parts.length > 1 ? parts[parts.length - 1] : "unknown";
}
/**
* @return une catégorie d'événement pour les rapports
*/
public String getEventCategory() {
if (eventType == null) return "OTHER";
if (eventType.startsWith("customer.subscription.")) {
return "SUBSCRIPTION";
} else if (eventType.startsWith("invoice.")) {
return "INVOICE";
} else if (eventType.startsWith("payment_method.")) {
return "PAYMENT_METHOD";
} else if (eventType.startsWith("customer.")) {
return "CUSTOMER";
} else {
return "OTHER";
}
}
/**
* Incrémente le nombre de tentatives
*/
public void incrementRetryCount() {
if (retryCount == null) {
retryCount = 1;
} else {
retryCount++;
}
}
/**
* Marque l'événement comme traité
*/
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 ignoré
*/
public void markAsIgnored(String reason) {
this.status = EventStatus.IGNORED;
this.errorMessage = reason;
this.processedAt = LocalDateTime.now();
}
/**
* Marque l'événement comme en cours de traitement
*/
public void markAsProcessing() {
this.status = EventStatus.PROCESSING;
}
}

View File

@ -0,0 +1,260 @@
package com.dh7789dev.xpeditis.entity;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.experimental.FieldDefaults;
import lombok.experimental.FieldNameConstants;
import java.time.LocalDateTime;
import java.util.UUID;
/**
* Entité JPA pour les méthodes de paiement Stripe
*/
@Entity
@Getter
@Setter
@NoArgsConstructor
@FieldNameConstants
@FieldDefaults(level = AccessLevel.PRIVATE)
@Table(name = "payment_methods")
@EntityListeners(org.springframework.data.jpa.domain.support.AuditingEntityListener.class)
public class PaymentMethodEntity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Column(columnDefinition = "BINARY(16)")
UUID id;
@Column(name = "stripe_payment_method_id", unique = true, nullable = false)
String stripePaymentMethodId;
@Enumerated(EnumType.STRING)
@Column(name = "type", nullable = false, length = 50)
PaymentMethodTypeEntity type;
@Column(name = "is_default", nullable = false)
Boolean isDefault = false;
@Column(name = "card_brand", length = 50)
String cardBrand;
@Column(name = "card_last4", length = 4)
String cardLast4;
@Column(name = "card_exp_month")
Integer cardExpMonth;
@Column(name = "card_exp_year")
Integer cardExpYear;
@Column(name = "bank_name", length = 100)
String bankName;
@Column(name = "company_id", columnDefinition = "BINARY(16)", nullable = false)
UUID companyId;
@Column(name = "created_at", updatable = false)
LocalDateTime createdAt;
@org.springframework.data.annotation.CreatedDate
@Column(name = "created_date", updatable = false, nullable = false)
java.time.Instant createdDate;
@org.springframework.data.annotation.LastModifiedDate
@Column(name = "modified_date", nullable = false)
java.time.Instant modifiedDate;
@org.springframework.data.annotation.CreatedBy
@Column(name = "created_by", updatable = false, nullable = false)
String createdBy = "SYSTEM";
@org.springframework.data.annotation.LastModifiedBy
@Column(name = "modified_by")
String modifiedBy;
@PrePersist
public void onCreate() {
if (id == null) {
id = UUID.randomUUID();
}
if (createdAt == null) {
createdAt = LocalDateTime.now();
}
}
/**
* @return true si c'est une carte de crédit/débit
*/
public boolean isCard() {
return type == PaymentMethodTypeEntity.CARD;
}
/**
* @return true si c'est un prélèvement bancaire SEPA
*/
public boolean isSepaDebit() {
return type == PaymentMethodTypeEntity.SEPA_DEBIT;
}
/**
* @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 Long.MAX_VALUE;
}
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.toString();
}
}
/**
* @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 switch (type) {
case CARD, SEPA_DEBIT -> true;
case BANCONTACT, GIROPAY, IDEAL -> false; // Ces méthodes ne supportent généralement pas les paiements récurrents
default -> false;
};
}
/**
* @return true si cette méthode de paiement nécessite une attention (expirée ou expirant bientôt)
*/
public boolean requiresAttention() {
return isCardExpired() || isCardExpiringSoon();
}
/**
* Marque cette méthode comme méthode par défaut
*/
public void setAsDefault() {
this.isDefault = true;
}
/**
* Retire le statut de méthode par défaut
*/
public void removeAsDefault() {
this.isDefault = false;
}
/**
* @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";
};
}
}
}
/**
* Énumération des types de méthodes de paiement
*/
enum PaymentMethodTypeEntity {
CARD, // Carte de crédit/débit
SEPA_DEBIT, // Prélèvement SEPA
BANCONTACT, // Bancontact (Belgique)
GIROPAY, // Giropay (Allemagne)
IDEAL, // iDEAL (Pays-Bas)
SOFORT, // Sofort (Europe)
P24, // Przelewy24 (Pologne)
EPS, // EPS (Autriche)
FPX, // FPX (Malaisie)
BACS_DEBIT // Prélèvement BACS (Royaume-Uni)
}

View File

@ -0,0 +1,224 @@
package com.dh7789dev.xpeditis.entity;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.experimental.FieldDefaults;
import lombok.experimental.FieldNameConstants;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Set;
import java.util.UUID;
/**
* Entité JPA pour les plans d'abonnement
*/
@Entity
@Getter
@Setter
@NoArgsConstructor
@FieldNameConstants
@FieldDefaults(level = AccessLevel.PRIVATE)
@Table(name = "plans")
@EntityListeners(org.springframework.data.jpa.domain.support.AuditingEntityListener.class)
public class PlanEntity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Column(columnDefinition = "BINARY(16)")
UUID id;
@Column(name = "name", length = 100, nullable = false, unique = true)
String name;
@Column(name = "type", length = 50, nullable = false, unique = true)
String type;
@Column(name = "stripe_price_id_monthly")
String stripePriceIdMonthly;
@Column(name = "stripe_price_id_yearly")
String stripePriceIdYearly;
@Column(name = "monthly_price", precision = 10, scale = 2)
BigDecimal monthlyPrice;
@Column(name = "yearly_price", precision = 10, scale = 2)
BigDecimal yearlyPrice;
@Column(name = "max_users", nullable = false)
Integer maxUsers;
@Convert(converter = FeaturesConverter.class)
@Column(name = "features", columnDefinition = "JSON")
Set<String> features;
@Column(name = "trial_duration_days", nullable = false)
Integer trialDurationDays = 14;
@Column(name = "is_active", nullable = false)
Boolean isActive = true;
@Column(name = "display_order")
Integer displayOrder;
@Convert(converter = MetadataConverter.class)
@Column(name = "metadata", columnDefinition = "JSON")
java.util.Map<String, Object> metadata;
@Column(name = "created_at", updatable = false)
LocalDateTime createdAt;
@Column(name = "updated_at")
LocalDateTime updatedAt;
@org.springframework.data.annotation.CreatedDate
@Column(name = "created_date", updatable = false, nullable = false)
java.time.Instant createdDate;
@org.springframework.data.annotation.LastModifiedDate
@Column(name = "modified_date", nullable = false)
java.time.Instant modifiedDate;
@org.springframework.data.annotation.CreatedBy
@Column(name = "created_by", updatable = false, nullable = false)
String createdBy = "SYSTEM";
@org.springframework.data.annotation.LastModifiedBy
@Column(name = "modified_by")
String modifiedBy;
@PrePersist
public void onCreate() {
if (id == null) {
id = UUID.randomUUID();
}
if (createdAt == null) {
createdAt = LocalDateTime.now();
}
updatedAt = LocalDateTime.now();
}
@PreUpdate
public void onUpdate() {
updatedAt = LocalDateTime.now();
}
/**
* @return true si le plan est disponible pour la souscription
*/
public boolean isAvailableForSubscription() {
return isActive != null && isActive;
}
/**
* @return le prix selon le cycle de facturation
*/
public BigDecimal getPriceForBillingCycle(String billingCycle) {
if ("YEARLY".equals(billingCycle)) {
return yearlyPrice;
}
return monthlyPrice;
}
/**
* @return l'ID Stripe selon le cycle de facturation
*/
public String getStripePriceIdForBillingCycle(String billingCycle) {
if ("YEARLY".equals(billingCycle)) {
return stripePriceIdYearly;
}
return stripePriceIdMonthly;
}
/**
* @return true si le plan support un nombre illimité d'utilisateurs
*/
public boolean hasUnlimitedUsers() {
return maxUsers != null && maxUsers == -1;
}
/**
* @return true si le plan inclut la fonctionnalité spécifiée
*/
public boolean hasFeature(String feature) {
return features != null && features.contains(feature);
}
/**
* @return le nombre de fonctionnalités incluses dans le plan
*/
public int getFeatureCount() {
return features != null ? features.size() : 0;
}
/**
* Converter pour sérialiser/désérialiser les fonctionnalités en JSON
*/
@Converter
public static class FeaturesConverter implements AttributeConverter<Set<String>, String> {
private final com.fasterxml.jackson.databind.ObjectMapper objectMapper = new com.fasterxml.jackson.databind.ObjectMapper();
@Override
public String convertToDatabaseColumn(Set<String> features) {
if (features == null || features.isEmpty()) {
return null;
}
try {
return objectMapper.writeValueAsString(features);
} catch (Exception e) {
throw new RuntimeException("Erreur lors de la sérialisation des features", e);
}
}
@Override
@SuppressWarnings("unchecked")
public Set<String> convertToEntityAttribute(String json) {
if (json == null || json.trim().isEmpty()) {
return java.util.Collections.emptySet();
}
try {
return objectMapper.readValue(json, Set.class);
} catch (Exception e) {
throw new RuntimeException("Erreur lors de la désérialisation des features", e);
}
}
}
/**
* Converter pour sérialiser/désérialiser les métadonnées en JSON
*/
@Converter
public static class MetadataConverter implements AttributeConverter<java.util.Map<String, Object>, String> {
private final com.fasterxml.jackson.databind.ObjectMapper objectMapper = new com.fasterxml.jackson.databind.ObjectMapper();
@Override
public String convertToDatabaseColumn(java.util.Map<String, Object> metadata) {
if (metadata == null || metadata.isEmpty()) {
return null;
}
try {
return objectMapper.writeValueAsString(metadata);
} catch (Exception e) {
throw new RuntimeException("Erreur lors de la sérialisation des metadata", e);
}
}
@Override
@SuppressWarnings("unchecked")
public java.util.Map<String, Object> convertToEntityAttribute(String json) {
if (json == null || json.trim().isEmpty()) {
return java.util.Collections.emptyMap();
}
try {
return objectMapper.readValue(json, java.util.Map.class);
} catch (Exception e) {
throw new RuntimeException("Erreur lors de la désérialisation des metadata", e);
}
}
}
}

View File

@ -0,0 +1,167 @@
package com.dh7789dev.xpeditis.entity;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.experimental.FieldDefaults;
import lombok.experimental.FieldNameConstants;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
/**
* Entité JPA pour les abonnements Stripe
*/
@Entity
@Getter
@Setter
@NoArgsConstructor
@FieldNameConstants
@FieldDefaults(level = AccessLevel.PRIVATE)
@Table(name = "subscriptions")
@EntityListeners(org.springframework.data.jpa.domain.support.AuditingEntityListener.class)
public class SubscriptionEntity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Column(columnDefinition = "BINARY(16)")
UUID id;
@Column(name = "stripe_subscription_id", unique = true, nullable = false)
String stripeSubscriptionId;
@Column(name = "stripe_customer_id", nullable = false)
String stripeCustomerId;
@Column(name = "stripe_price_id", nullable = false)
String stripePriceId;
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false)
SubscriptionStatusEntity status;
@Column(name = "current_period_start", nullable = false)
LocalDateTime currentPeriodStart;
@Column(name = "current_period_end", nullable = false)
LocalDateTime currentPeriodEnd;
@Column(name = "cancel_at_period_end", nullable = false)
boolean cancelAtPeriodEnd = false;
@Enumerated(EnumType.STRING)
@Column(name = "billing_cycle", nullable = false)
BillingCycleEntity billingCycle;
@Column(name = "next_billing_date")
LocalDateTime nextBillingDate;
@Column(name = "trial_end_date")
LocalDateTime trialEndDate;
@OneToOne(mappedBy = "subscription", cascade = CascadeType.ALL)
LicenseEntity license;
@OneToMany(mappedBy = "subscription", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
List<InvoiceEntity> invoices;
@Column(name = "created_at", updatable = false)
LocalDateTime createdAt;
@Column(name = "updated_at")
LocalDateTime updatedAt;
@org.springframework.data.annotation.CreatedDate
@Column(name = "created_date", updatable = false, nullable = false)
java.time.Instant createdDate;
@org.springframework.data.annotation.LastModifiedDate
@Column(name = "modified_date", nullable = false)
java.time.Instant modifiedDate;
@org.springframework.data.annotation.CreatedBy
@Column(name = "created_by", updatable = false, nullable = false)
String createdBy = "SYSTEM";
@org.springframework.data.annotation.LastModifiedBy
@Column(name = "modified_by")
String modifiedBy;
@PrePersist
public void onCreate() {
if (id == null) {
id = UUID.randomUUID();
}
if (createdAt == null) {
createdAt = LocalDateTime.now();
}
updatedAt = LocalDateTime.now();
}
@PreUpdate
public void onUpdate() {
updatedAt = LocalDateTime.now();
}
/**
* @return true si l'abonnement est actif
*/
public boolean isActive() {
return status == SubscriptionStatusEntity.ACTIVE;
}
/**
* @return true si l'abonnement est en période d'essai
*/
public boolean isTrialing() {
return status == SubscriptionStatusEntity.TRIALING;
}
/**
* @return true si l'abonnement est en retard de paiement
*/
public boolean isPastDue() {
return status == SubscriptionStatusEntity.PAST_DUE;
}
/**
* @return true si l'abonnement est annulé
*/
public boolean isCanceled() {
return status == SubscriptionStatusEntity.CANCELED;
}
/**
* @return true si l'abonnement peut être réactivé
*/
public boolean canBeReactivated() {
return status == SubscriptionStatusEntity.CANCELED
|| status == SubscriptionStatusEntity.PAST_DUE
|| status == SubscriptionStatusEntity.UNPAID;
}
/**
* @return true si l'abonnement nécessite une attention
*/
public boolean requiresAttention() {
return status == SubscriptionStatusEntity.PAST_DUE
|| status == SubscriptionStatusEntity.UNPAID
|| status == SubscriptionStatusEntity.INCOMPLETE
|| status == SubscriptionStatusEntity.INCOMPLETE_EXPIRED;
}
/**
* @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);
}
}

View File

@ -0,0 +1,15 @@
package com.dh7789dev.xpeditis.entity;
/**
* Énumération des statuts d'abonnement Stripe
*/
public enum SubscriptionStatusEntity {
INCOMPLETE, // Abonnement créé mais paiement initial non confirmé
INCOMPLETE_EXPIRED, // Abonnement expiré avant confirmation du paiement
TRIALING, // En période d'essai
ACTIVE, // Actif et à jour
PAST_DUE, // En retard de paiement mais toujours actif
CANCELED, // Annulé (peut être réactivé avant la fin de période)
UNPAID, // Impayé et suspendu
PAUSED // Suspendu temporairement
}

View File

@ -0,0 +1,134 @@
package com.dh7789dev.xpeditis.mapper;
import com.dh7789dev.xpeditis.dto.app.InvoiceLineItem;
import com.dh7789dev.xpeditis.entity.InvoiceLineItemEntity;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.stream.Collectors;
/**
* Mapper entre InvoiceLineItem (domaine) et InvoiceLineItemEntity (JPA)
*/
@Component
public class InvoiceLineItemMapper {
/**
* Convertit une entité JPA en objet domaine
*/
public InvoiceLineItem toDomain(InvoiceLineItemEntity entity) {
if (entity == null) {
return null;
}
InvoiceLineItem lineItem = new InvoiceLineItem();
lineItem.setId(entity.getId());
lineItem.setDescription(entity.getDescription());
lineItem.setQuantity(entity.getQuantity());
lineItem.setUnitPrice(entity.getUnitPrice());
lineItem.setAmount(entity.getAmount());
lineItem.setStripePriceId(entity.getStripePriceId());
lineItem.setPeriodStart(entity.getPeriodStart());
lineItem.setPeriodEnd(entity.getPeriodEnd());
lineItem.setProrated(entity.getProrated());
lineItem.setCreatedAt(entity.getCreatedAt());
// L'ID de la facture sera défini par le mapper parent
if (entity.getInvoice() != null) {
lineItem.setInvoiceId(entity.getInvoice().getId());
}
return lineItem;
}
/**
* Convertit un objet domaine en entité JPA
*/
public InvoiceLineItemEntity toEntity(InvoiceLineItem domain) {
if (domain == null) {
return null;
}
InvoiceLineItemEntity entity = new InvoiceLineItemEntity();
entity.setId(domain.getId());
entity.setDescription(domain.getDescription());
entity.setQuantity(domain.getQuantity());
entity.setUnitPrice(domain.getUnitPrice());
entity.setAmount(domain.getAmount());
entity.setStripePriceId(domain.getStripePriceId());
entity.setPeriodStart(domain.getPeriodStart());
entity.setPeriodEnd(domain.getPeriodEnd());
entity.setProrated(domain.getProrated());
entity.setCreatedAt(domain.getCreatedAt());
// La relation avec la facture sera définie séparément
return entity;
}
/**
* Met à jour une entité existante avec les données du domaine
*/
public void updateEntity(InvoiceLineItemEntity entity, InvoiceLineItem domain) {
if (entity == null || domain == null) {
return;
}
entity.setDescription(domain.getDescription());
entity.setQuantity(domain.getQuantity());
entity.setUnitPrice(domain.getUnitPrice());
entity.setAmount(domain.getAmount());
entity.setStripePriceId(domain.getStripePriceId());
entity.setPeriodStart(domain.getPeriodStart());
entity.setPeriodEnd(domain.getPeriodEnd());
entity.setProrated(domain.getProrated());
}
/**
* Convertit une liste d'entités en liste d'objets domaine
*/
public List<InvoiceLineItem> toDomainList(List<InvoiceLineItemEntity> entities) {
if (entities == null) {
return null;
}
return entities.stream()
.map(this::toDomain)
.collect(Collectors.toList());
}
/**
* Convertit une liste d'objets domaine en liste d'entités
*/
public List<InvoiceLineItemEntity> toEntityList(List<InvoiceLineItem> domains) {
if (domains == null) {
return null;
}
return domains.stream()
.map(this::toEntity)
.collect(Collectors.toList());
}
/**
* Crée une ligne de facture minimale (sans dates de période)
*/
public InvoiceLineItem toDomainMinimal(InvoiceLineItemEntity entity) {
if (entity == null) {
return null;
}
InvoiceLineItem lineItem = new InvoiceLineItem();
lineItem.setId(entity.getId());
lineItem.setDescription(entity.getDescription());
lineItem.setQuantity(entity.getQuantity());
lineItem.setAmount(entity.getAmount());
lineItem.setProrated(entity.getProrated());
if (entity.getInvoice() != null) {
lineItem.setInvoiceId(entity.getInvoice().getId());
}
return lineItem;
}
}

View File

@ -0,0 +1,239 @@
package com.dh7789dev.xpeditis.mapper;
import com.dh7789dev.xpeditis.dto.app.Invoice;
import com.dh7789dev.xpeditis.dto.app.InvoiceLineItem;
import com.dh7789dev.xpeditis.dto.app.InvoiceStatus;
import com.dh7789dev.xpeditis.entity.InvoiceEntity;
import com.dh7789dev.xpeditis.entity.InvoiceStatusEntity;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.stream.Collectors;
/**
* Mapper entre Invoice (domaine) et InvoiceEntity (JPA)
*/
@Component
public class InvoiceMapper {
private final SubscriptionMapper subscriptionMapper;
private final InvoiceLineItemMapper lineItemMapper;
@Autowired
public InvoiceMapper(SubscriptionMapper subscriptionMapper, InvoiceLineItemMapper lineItemMapper) {
this.subscriptionMapper = subscriptionMapper;
this.lineItemMapper = lineItemMapper;
}
/**
* Convertit une entité JPA en objet domaine
*/
public Invoice toDomain(InvoiceEntity entity) {
if (entity == null) {
return null;
}
Invoice invoice = new Invoice();
invoice.setId(entity.getId());
invoice.setStripeInvoiceId(entity.getStripeInvoiceId());
invoice.setInvoiceNumber(entity.getInvoiceNumber());
invoice.setStatus(mapStatusToDomain(entity.getStatus()));
invoice.setAmountDue(entity.getAmountDue());
invoice.setAmountPaid(entity.getAmountPaid());
invoice.setCurrency(entity.getCurrency());
invoice.setBillingPeriodStart(entity.getBillingPeriodStart());
invoice.setBillingPeriodEnd(entity.getBillingPeriodEnd());
invoice.setDueDate(entity.getDueDate());
invoice.setPaidAt(entity.getPaidAt());
invoice.setInvoicePdfUrl(entity.getInvoicePdfUrl());
invoice.setHostedInvoiceUrl(entity.getHostedInvoiceUrl());
invoice.setAttemptCount(entity.getAttemptCount());
invoice.setCreatedAt(entity.getCreatedAt());
// Mapping des relations (attention aux cycles)
if (entity.getSubscription() != null) {
// On ne mappe pas la subscription complète pour éviter les cycles infinis
// Elle sera chargée séparément si nécessaire
invoice.setSubscriptionId(entity.getSubscription().getId());
}
// Mapping des lignes de facture
if (entity.getLineItems() != null && !entity.getLineItems().isEmpty()) {
List<InvoiceLineItem> lineItems = entity.getLineItems().stream()
.map(lineItemEntity -> {
InvoiceLineItem lineItem = lineItemMapper.toDomain(lineItemEntity);
// Éviter le cycle en définissant la référence à la facture
lineItem.setInvoiceId(invoice.getId());
return lineItem;
})
.collect(Collectors.toList());
invoice.setLineItems(lineItems);
}
return invoice;
}
/**
* Convertit un objet domaine en entité JPA
*/
public InvoiceEntity toEntity(Invoice domain) {
if (domain == null) {
return null;
}
InvoiceEntity entity = new InvoiceEntity();
entity.setId(domain.getId());
entity.setStripeInvoiceId(domain.getStripeInvoiceId());
entity.setInvoiceNumber(domain.getInvoiceNumber());
entity.setStatus(mapStatusToEntity(domain.getStatus()));
entity.setAmountDue(domain.getAmountDue());
entity.setAmountPaid(domain.getAmountPaid());
entity.setCurrency(domain.getCurrency());
entity.setBillingPeriodStart(domain.getBillingPeriodStart());
entity.setBillingPeriodEnd(domain.getBillingPeriodEnd());
entity.setDueDate(domain.getDueDate());
entity.setPaidAt(domain.getPaidAt());
entity.setInvoicePdfUrl(domain.getInvoicePdfUrl());
entity.setHostedInvoiceUrl(domain.getHostedInvoiceUrl());
entity.setAttemptCount(domain.getAttemptCount());
entity.setCreatedAt(domain.getCreatedAt());
// Les relations seront définies séparément
return entity;
}
/**
* Met à jour une entité existante avec les données du domaine
*/
public void updateEntity(InvoiceEntity entity, Invoice domain) {
if (entity == null || domain == null) {
return;
}
entity.setInvoiceNumber(domain.getInvoiceNumber());
entity.setStatus(mapStatusToEntity(domain.getStatus()));
entity.setAmountDue(domain.getAmountDue());
entity.setAmountPaid(domain.getAmountPaid());
entity.setCurrency(domain.getCurrency());
entity.setBillingPeriodStart(domain.getBillingPeriodStart());
entity.setBillingPeriodEnd(domain.getBillingPeriodEnd());
entity.setDueDate(domain.getDueDate());
entity.setPaidAt(domain.getPaidAt());
entity.setInvoicePdfUrl(domain.getInvoicePdfUrl());
entity.setHostedInvoiceUrl(domain.getHostedInvoiceUrl());
entity.setAttemptCount(domain.getAttemptCount());
}
/**
* Convertit une liste d'entités en liste d'objets domaine
*/
public List<Invoice> toDomainList(List<InvoiceEntity> entities) {
if (entities == null) {
return null;
}
return entities.stream()
.map(this::toDomain)
.collect(Collectors.toList());
}
/**
* Convertit une liste d'objets domaine en liste d'entités
*/
public List<InvoiceEntity> toEntityList(List<Invoice> domains) {
if (domains == null) {
return null;
}
return domains.stream()
.map(this::toEntity)
.collect(Collectors.toList());
}
/**
* Crée une facture domaine minimale (sans lignes de facture)
*/
public Invoice toDomainMinimal(InvoiceEntity entity) {
if (entity == null) {
return null;
}
Invoice invoice = new Invoice();
invoice.setId(entity.getId());
invoice.setStripeInvoiceId(entity.getStripeInvoiceId());
invoice.setInvoiceNumber(entity.getInvoiceNumber());
invoice.setStatus(mapStatusToDomain(entity.getStatus()));
invoice.setAmountDue(entity.getAmountDue());
invoice.setAmountPaid(entity.getAmountPaid());
invoice.setCurrency(entity.getCurrency());
invoice.setDueDate(entity.getDueDate());
invoice.setPaidAt(entity.getPaidAt());
invoice.setAttemptCount(entity.getAttemptCount());
invoice.setCreatedAt(entity.getCreatedAt());
if (entity.getSubscription() != null) {
invoice.setSubscriptionId(entity.getSubscription().getId());
}
return invoice;
}
/**
* Met à jour seulement le statut de paiement
*/
public void updatePaymentStatus(InvoiceEntity entity, InvoiceStatus status, java.time.LocalDateTime paidAt, java.math.BigDecimal amountPaid) {
if (entity == null) {
return;
}
entity.setStatus(mapStatusToEntity(status));
entity.setPaidAt(paidAt);
entity.setAmountPaid(amountPaid);
}
/**
* Incrémente le nombre de tentatives
*/
public void incrementAttemptCount(InvoiceEntity entity) {
if (entity == null) {
return;
}
Integer currentCount = entity.getAttemptCount();
entity.setAttemptCount(currentCount != null ? currentCount + 1 : 1);
}
// ===== MÉTHODES DE MAPPING DES ÉNUMÉRATIONS =====
private InvoiceStatus mapStatusToDomain(InvoiceStatusEntity status) {
if (status == null) {
return null;
}
return switch (status) {
case DRAFT -> InvoiceStatus.DRAFT;
case OPEN -> InvoiceStatus.OPEN;
case PAID -> InvoiceStatus.PAID;
case PAYMENT_FAILED -> InvoiceStatus.PAYMENT_FAILED;
case VOIDED -> InvoiceStatus.VOIDED;
case UNCOLLECTIBLE -> InvoiceStatus.UNCOLLECTIBLE;
};
}
private InvoiceStatusEntity mapStatusToEntity(InvoiceStatus status) {
if (status == null) {
return null;
}
return switch (status) {
case DRAFT -> InvoiceStatusEntity.DRAFT;
case OPEN -> InvoiceStatusEntity.OPEN;
case PAID -> InvoiceStatusEntity.PAID;
case PAYMENT_FAILED -> InvoiceStatusEntity.PAYMENT_FAILED;
case VOIDED -> InvoiceStatusEntity.VOIDED;
case UNCOLLECTIBLE -> InvoiceStatusEntity.UNCOLLECTIBLE;
};
}
}

View File

@ -0,0 +1,156 @@
package com.dh7789dev.xpeditis.mapper;
import com.dh7789dev.xpeditis.dto.app.EventStatus;
import com.dh7789dev.xpeditis.dto.app.PaymentEvent;
import com.dh7789dev.xpeditis.entity.PaymentEventEntity;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.stream.Collectors;
/**
* Mapper entre PaymentEvent (domaine) et PaymentEventEntity (JPA)
*/
@Component
public class PaymentEventMapper {
private final SubscriptionMapper subscriptionMapper;
@Autowired
public PaymentEventMapper(SubscriptionMapper subscriptionMapper) {
this.subscriptionMapper = subscriptionMapper;
}
/**
* Convertit une entité JPA en objet domaine
*/
public PaymentEvent toDomain(PaymentEventEntity entity) {
if (entity == null) {
return null;
}
PaymentEvent event = new PaymentEvent();
event.setId(entity.getId());
event.setStripeEventId(entity.getStripeEventId());
event.setEventType(entity.getEventType());
event.setStatus(entity.getStatus());
event.setPayload(entity.getPayload());
event.setProcessedAt(entity.getProcessedAt());
event.setErrorMessage(entity.getErrorMessage());
event.setRetryCount(entity.getRetryCount());
event.setCreatedAt(entity.getCreatedAt());
// Mapping de la relation subscription (sans cycle infini)
if (entity.getSubscription() != null) {
event.setSubscriptionId(entity.getSubscription().getId());
// On ne mappe pas la subscription complète ici pour éviter les cycles
// Si nécessaire, elle sera chargée séparément
}
return event;
}
/**
* Convertit un objet domaine en entité JPA
*/
public PaymentEventEntity toEntity(PaymentEvent domain) {
if (domain == null) {
return null;
}
PaymentEventEntity entity = new PaymentEventEntity();
entity.setId(domain.getId());
entity.setStripeEventId(domain.getStripeEventId());
entity.setEventType(domain.getEventType());
entity.setStatus(domain.getStatus());
entity.setPayload(domain.getPayload());
entity.setProcessedAt(domain.getProcessedAt());
entity.setErrorMessage(domain.getErrorMessage());
entity.setRetryCount(domain.getRetryCount());
entity.setCreatedAt(domain.getCreatedAt());
// La relation subscription sera définie séparément
return entity;
}
/**
* Met à jour une entité existante avec les données du domaine
*/
public void updateEntity(PaymentEventEntity entity, PaymentEvent domain) {
if (entity == null || domain == null) {
return;
}
entity.setEventType(domain.getEventType());
entity.setStatus(domain.getStatus());
entity.setPayload(domain.getPayload());
entity.setProcessedAt(domain.getProcessedAt());
entity.setErrorMessage(domain.getErrorMessage());
entity.setRetryCount(domain.getRetryCount());
}
/**
* Convertit une liste d'entités en liste d'objets domaine
*/
public List<PaymentEvent> toDomainList(List<PaymentEventEntity> entities) {
if (entities == null) {
return null;
}
return entities.stream()
.map(this::toDomain)
.collect(Collectors.toList());
}
/**
* Convertit une liste d'objets domaine en liste d'entités
*/
public List<PaymentEventEntity> toEntityList(List<PaymentEvent> domains) {
if (domains == null) {
return null;
}
return domains.stream()
.map(this::toEntity)
.collect(Collectors.toList());
}
/**
* Crée un événement domaine minimal (pour les cas on n'a besoin que des infos de base)
*/
public PaymentEvent toDomainMinimal(PaymentEventEntity entity) {
if (entity == null) {
return null;
}
PaymentEvent event = new PaymentEvent();
event.setId(entity.getId());
event.setStripeEventId(entity.getStripeEventId());
event.setEventType(entity.getEventType());
event.setStatus(entity.getStatus());
event.setRetryCount(entity.getRetryCount());
event.setCreatedAt(entity.getCreatedAt());
event.setProcessedAt(entity.getProcessedAt());
event.setErrorMessage(entity.getErrorMessage());
// Pas de payload ni de relations pour une version minimale
return event;
}
/**
* Mappe seulement les données de statut (pour les mises à jour rapides)
*/
public void updateStatusOnly(PaymentEventEntity entity, PaymentEvent domain) {
if (entity == null || domain == null) {
return;
}
entity.setStatus(domain.getStatus());
entity.setProcessedAt(domain.getProcessedAt());
entity.setErrorMessage(domain.getErrorMessage());
entity.setRetryCount(domain.getRetryCount());
}
}

View File

@ -0,0 +1,195 @@
package com.dh7789dev.xpeditis.mapper;
import com.dh7789dev.xpeditis.dto.app.PaymentMethod;
import com.dh7789dev.xpeditis.dto.app.PaymentMethodType;
import com.dh7789dev.xpeditis.entity.PaymentMethodEntity;
import com.dh7789dev.xpeditis.entity.PaymentMethodTypeEntity;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.stream.Collectors;
/**
* Mapper entre PaymentMethod (domaine) et PaymentMethodEntity (JPA)
*/
@Component
public class PaymentMethodMapper {
/**
* Convertit une entité JPA en objet domaine
*/
public PaymentMethod toDomain(PaymentMethodEntity entity) {
if (entity == null) {
return null;
}
PaymentMethod paymentMethod = new PaymentMethod();
paymentMethod.setId(entity.getId());
paymentMethod.setStripePaymentMethodId(entity.getStripePaymentMethodId());
paymentMethod.setType(mapTypeToDomain(entity.getType()));
paymentMethod.setIsDefault(entity.getIsDefault());
paymentMethod.setCardBrand(entity.getCardBrand());
paymentMethod.setCardLast4(entity.getCardLast4());
paymentMethod.setCardExpMonth(entity.getCardExpMonth());
paymentMethod.setCardExpYear(entity.getCardExpYear());
paymentMethod.setBankName(entity.getBankName());
paymentMethod.setCompanyId(entity.getCompanyId());
paymentMethod.setCreatedAt(entity.getCreatedAt());
return paymentMethod;
}
/**
* Convertit un objet domaine en entité JPA
*/
public PaymentMethodEntity toEntity(PaymentMethod domain) {
if (domain == null) {
return null;
}
PaymentMethodEntity entity = new PaymentMethodEntity();
entity.setId(domain.getId());
entity.setStripePaymentMethodId(domain.getStripePaymentMethodId());
entity.setType(mapTypeToEntity(domain.getType()));
entity.setIsDefault(domain.getIsDefault());
entity.setCardBrand(domain.getCardBrand());
entity.setCardLast4(domain.getCardLast4());
entity.setCardExpMonth(domain.getCardExpMonth());
entity.setCardExpYear(domain.getCardExpYear());
entity.setBankName(domain.getBankName());
entity.setCompanyId(domain.getCompanyId());
entity.setCreatedAt(domain.getCreatedAt());
return entity;
}
/**
* Met à jour une entité existante avec les données du domaine
*/
public void updateEntity(PaymentMethodEntity entity, PaymentMethod domain) {
if (entity == null || domain == null) {
return;
}
entity.setType(mapTypeToEntity(domain.getType()));
entity.setIsDefault(domain.getIsDefault());
entity.setCardBrand(domain.getCardBrand());
entity.setCardLast4(domain.getCardLast4());
entity.setCardExpMonth(domain.getCardExpMonth());
entity.setCardExpYear(domain.getCardExpYear());
entity.setBankName(domain.getBankName());
}
/**
* Convertit une liste d'entités en liste d'objets domaine
*/
public List<PaymentMethod> toDomainList(List<PaymentMethodEntity> entities) {
if (entities == null) {
return null;
}
return entities.stream()
.map(this::toDomain)
.collect(Collectors.toList());
}
/**
* Convertit une liste d'objets domaine en liste d'entités
*/
public List<PaymentMethodEntity> toEntityList(List<PaymentMethod> domains) {
if (domains == null) {
return null;
}
return domains.stream()
.map(this::toEntity)
.collect(Collectors.toList());
}
/**
* Crée une méthode de paiement minimale (sans détails sensibles)
*/
public PaymentMethod toDomainMinimal(PaymentMethodEntity entity) {
if (entity == null) {
return null;
}
PaymentMethod paymentMethod = new PaymentMethod();
paymentMethod.setId(entity.getId());
paymentMethod.setType(mapTypeToDomain(entity.getType()));
paymentMethod.setIsDefault(entity.getIsDefault());
paymentMethod.setCardBrand(entity.getCardBrand());
paymentMethod.setCardLast4(entity.getCardLast4());
paymentMethod.setCreatedAt(entity.getCreatedAt());
paymentMethod.setCompanyId(entity.getCompanyId());
// Pas d'informations sensibles comme les dates d'expiration détaillées
return paymentMethod;
}
/**
* Met à jour seulement le statut par défaut
*/
public void updateDefaultStatus(PaymentMethodEntity entity, boolean isDefault) {
if (entity == null) {
return;
}
entity.setIsDefault(isDefault);
}
/**
* Met à jour les informations de carte (suite à une mise à jour Stripe)
*/
public void updateCardInfo(PaymentMethodEntity entity, String cardBrand, String cardLast4, Integer expMonth, Integer expYear) {
if (entity == null) {
return;
}
entity.setCardBrand(cardBrand);
entity.setCardLast4(cardLast4);
entity.setCardExpMonth(expMonth);
entity.setCardExpYear(expYear);
}
// ===== MÉTHODES DE MAPPING DES ÉNUMÉRATIONS =====
private PaymentMethodType mapTypeToDomain(PaymentMethodTypeEntity type) {
if (type == null) {
return null;
}
return switch (type) {
case CARD -> PaymentMethodType.CARD;
case SEPA_DEBIT -> PaymentMethodType.SEPA_DEBIT;
case BANCONTACT -> PaymentMethodType.BANCONTACT;
case GIROPAY -> PaymentMethodType.GIROPAY;
case IDEAL -> PaymentMethodType.IDEAL;
case SOFORT -> PaymentMethodType.SOFORT;
case P24 -> PaymentMethodType.P24;
case EPS -> PaymentMethodType.EPS;
case FPX -> PaymentMethodType.FPX;
case BACS_DEBIT -> PaymentMethodType.BACS_DEBIT;
};
}
private PaymentMethodTypeEntity mapTypeToEntity(PaymentMethodType type) {
if (type == null) {
return null;
}
return switch (type) {
case CARD -> PaymentMethodTypeEntity.CARD;
case SEPA_DEBIT -> PaymentMethodTypeEntity.SEPA_DEBIT;
case BANCONTACT -> PaymentMethodTypeEntity.BANCONTACT;
case GIROPAY -> PaymentMethodTypeEntity.GIROPAY;
case IDEAL -> PaymentMethodTypeEntity.IDEAL;
case SOFORT -> PaymentMethodTypeEntity.SOFORT;
case P24 -> PaymentMethodTypeEntity.P24;
case EPS -> PaymentMethodTypeEntity.EPS;
case FPX -> PaymentMethodTypeEntity.FPX;
case BACS_DEBIT -> PaymentMethodTypeEntity.BACS_DEBIT;
};
}
}

View File

@ -0,0 +1,211 @@
package com.dh7789dev.xpeditis.mapper;
import com.dh7789dev.xpeditis.dto.app.Plan;
import com.dh7789dev.xpeditis.entity.PlanEntity;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
* Mapper entre Plan (domaine) et PlanEntity (JPA)
*/
@Component
public class PlanMapper {
/**
* Convertit une entité JPA en objet domaine
*/
public Plan toDomain(PlanEntity entity) {
if (entity == null) {
return null;
}
Plan plan = new Plan();
plan.setId(entity.getId());
plan.setName(entity.getName());
plan.setType(entity.getType());
plan.setStripePriceIdMonthly(entity.getStripePriceIdMonthly());
plan.setStripePriceIdYearly(entity.getStripePriceIdYearly());
plan.setMonthlyPrice(entity.getMonthlyPrice());
plan.setYearlyPrice(entity.getYearlyPrice());
plan.setMaxUsers(entity.getMaxUsers());
plan.setFeatures(entity.getFeatures());
plan.setTrialDurationDays(entity.getTrialDurationDays());
plan.setIsActive(entity.getIsActive());
plan.setDisplayOrder(entity.getDisplayOrder());
plan.setMetadata(entity.getMetadata());
plan.setCreatedAt(entity.getCreatedAt());
plan.setUpdatedAt(entity.getUpdatedAt());
return plan;
}
/**
* Convertit un objet domaine en entité JPA
*/
public PlanEntity toEntity(Plan domain) {
if (domain == null) {
return null;
}
PlanEntity entity = new PlanEntity();
entity.setId(domain.getId());
entity.setName(domain.getName());
entity.setType(domain.getType());
entity.setStripePriceIdMonthly(domain.getStripePriceIdMonthly());
entity.setStripePriceIdYearly(domain.getStripePriceIdYearly());
entity.setMonthlyPrice(domain.getMonthlyPrice());
entity.setYearlyPrice(domain.getYearlyPrice());
entity.setMaxUsers(domain.getMaxUsers());
entity.setFeatures(domain.getFeatures());
entity.setTrialDurationDays(domain.getTrialDurationDays());
entity.setIsActive(domain.getIsActive());
entity.setDisplayOrder(domain.getDisplayOrder());
entity.setMetadata(domain.getMetadata());
entity.setCreatedAt(domain.getCreatedAt());
entity.setUpdatedAt(domain.getUpdatedAt());
return entity;
}
/**
* Met à jour une entité existante avec les données du domaine
*/
public void updateEntity(PlanEntity entity, Plan domain) {
if (entity == null || domain == null) {
return;
}
entity.setName(domain.getName());
entity.setType(domain.getType());
entity.setStripePriceIdMonthly(domain.getStripePriceIdMonthly());
entity.setStripePriceIdYearly(domain.getStripePriceIdYearly());
entity.setMonthlyPrice(domain.getMonthlyPrice());
entity.setYearlyPrice(domain.getYearlyPrice());
entity.setMaxUsers(domain.getMaxUsers());
entity.setFeatures(domain.getFeatures());
entity.setTrialDurationDays(domain.getTrialDurationDays());
entity.setIsActive(domain.getIsActive());
entity.setDisplayOrder(domain.getDisplayOrder());
entity.setMetadata(domain.getMetadata());
entity.setUpdatedAt(domain.getUpdatedAt());
}
/**
* Convertit une liste d'entités en liste d'objets domaine
*/
public List<Plan> toDomainList(List<PlanEntity> entities) {
if (entities == null) {
return null;
}
return entities.stream()
.map(this::toDomain)
.collect(Collectors.toList());
}
/**
* Convertit une liste d'objets domaine en liste d'entités
*/
public List<PlanEntity> toEntityList(List<Plan> domains) {
if (domains == null) {
return null;
}
return domains.stream()
.map(this::toEntity)
.collect(Collectors.toList());
}
/**
* Crée un plan domaine minimal (pour les listes et aperçus)
*/
public Plan toDomainMinimal(PlanEntity entity) {
if (entity == null) {
return null;
}
Plan plan = new Plan();
plan.setId(entity.getId());
plan.setName(entity.getName());
plan.setType(entity.getType());
plan.setMonthlyPrice(entity.getMonthlyPrice());
plan.setYearlyPrice(entity.getYearlyPrice());
plan.setMaxUsers(entity.getMaxUsers());
plan.setIsActive(entity.getIsActive());
plan.setDisplayOrder(entity.getDisplayOrder());
// Features simplifiées (juste le count)
if (entity.getFeatures() != null) {
plan.setFeatures(entity.getFeatures());
}
return plan;
}
/**
* Met à jour seulement les prix et IDs Stripe (pour les synchronisations)
*/
public void updatePricingInfo(PlanEntity entity, Plan domain) {
if (entity == null || domain == null) {
return;
}
entity.setStripePriceIdMonthly(domain.getStripePriceIdMonthly());
entity.setStripePriceIdYearly(domain.getStripePriceIdYearly());
entity.setMonthlyPrice(domain.getMonthlyPrice());
entity.setYearlyPrice(domain.getYearlyPrice());
entity.setUpdatedAt(domain.getUpdatedAt());
}
/**
* Met à jour seulement les fonctionnalités
*/
public void updateFeatures(PlanEntity entity, Set<String> features) {
if (entity == null) {
return;
}
entity.setFeatures(features);
entity.setUpdatedAt(java.time.LocalDateTime.now());
}
/**
* Met à jour seulement le statut actif
*/
public void updateActiveStatus(PlanEntity entity, boolean isActive) {
if (entity == null) {
return;
}
entity.setIsActive(isActive);
entity.setUpdatedAt(java.time.LocalDateTime.now());
}
/**
* Met à jour seulement l'ordre d'affichage
*/
public void updateDisplayOrder(PlanEntity entity, int displayOrder) {
if (entity == null) {
return;
}
entity.setDisplayOrder(displayOrder);
entity.setUpdatedAt(java.time.LocalDateTime.now());
}
/**
* Met à jour les métadonnées
*/
public void updateMetadata(PlanEntity entity, Map<String, Object> metadata) {
if (entity == null) {
return;
}
entity.setMetadata(metadata);
entity.setUpdatedAt(java.time.LocalDateTime.now());
}
}

View File

@ -0,0 +1,201 @@
package com.dh7789dev.xpeditis.mapper;
import com.dh7789dev.xpeditis.dto.app.*;
import com.dh7789dev.xpeditis.entity.SubscriptionEntity;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.stream.Collectors;
/**
* Mapper entre Subscription (domaine) et SubscriptionEntity (JPA)
*/
@Component
public class SubscriptionMapper {
private final LicenseMapper licenseMapper;
private final InvoiceMapper invoiceMapper;
@Autowired
public SubscriptionMapper(LicenseMapper licenseMapper, InvoiceMapper invoiceMapper) {
this.licenseMapper = licenseMapper;
this.invoiceMapper = invoiceMapper;
}
/**
* Convertit une entité JPA en objet domaine
*/
public Subscription toDomain(SubscriptionEntity entity) {
if (entity == null) {
return null;
}
Subscription subscription = new Subscription();
subscription.setId(entity.getId());
subscription.setStripeSubscriptionId(entity.getStripeSubscriptionId());
subscription.setStripeCustomerId(entity.getStripeCustomerId());
subscription.setStripePriceId(entity.getStripePriceId());
subscription.setStatus(mapStatusToDomain(entity.getStatus()));
subscription.setCurrentPeriodStart(entity.getCurrentPeriodStart());
subscription.setCurrentPeriodEnd(entity.getCurrentPeriodEnd());
subscription.setCancelAtPeriodEnd(entity.getCancelAtPeriodEnd());
subscription.setBillingCycle(mapBillingCycleToDomain(entity.getBillingCycle()));
subscription.setNextBillingDate(entity.getNextBillingDate());
subscription.setTrialEndDate(entity.getTrialEndDate());
subscription.setCreatedAt(entity.getCreatedAt());
subscription.setUpdatedAt(entity.getUpdatedAt());
// Mapping des relations (sans cycles infinis)
if (entity.getLicense() != null) {
subscription.setLicense(licenseMapper.toDomain(entity.getLicense()));
}
if (entity.getInvoices() != null && !entity.getInvoices().isEmpty()) {
List<Invoice> invoices = entity.getInvoices().stream()
.map(invoiceEntity -> {
Invoice invoice = invoiceMapper.toDomain(invoiceEntity);
// Éviter le cycle infini en ne remappant pas la subscription
invoice.setSubscription(subscription);
return invoice;
})
.collect(Collectors.toList());
subscription.setInvoices(invoices);
}
return subscription;
}
/**
* Convertit un objet domaine en entité JPA
*/
public SubscriptionEntity toEntity(Subscription domain) {
if (domain == null) {
return null;
}
SubscriptionEntity entity = new SubscriptionEntity();
entity.setId(domain.getId());
entity.setStripeSubscriptionId(domain.getStripeSubscriptionId());
entity.setStripeCustomerId(domain.getStripeCustomerId());
entity.setStripePriceId(domain.getStripePriceId());
entity.setStatus(mapStatusToEntity(domain.getStatus()));
entity.setCurrentPeriodStart(domain.getCurrentPeriodStart());
entity.setCurrentPeriodEnd(domain.getCurrentPeriodEnd());
entity.setCancelAtPeriodEnd(domain.getCancelAtPeriodEnd());
entity.setBillingCycle(mapBillingCycleToEntity(domain.getBillingCycle()));
entity.setNextBillingDate(domain.getNextBillingDate());
entity.setTrialEndDate(domain.getTrialEndDate());
entity.setCreatedAt(domain.getCreatedAt());
entity.setUpdatedAt(domain.getUpdatedAt());
// Relations mappées séparément pour éviter les cycles
return entity;
}
/**
* Met à jour une entité existante avec les données du domaine
*/
public void updateEntity(SubscriptionEntity entity, Subscription domain) {
if (entity == null || domain == null) {
return;
}
entity.setStripeSubscriptionId(domain.getStripeSubscriptionId());
entity.setStripeCustomerId(domain.getStripeCustomerId());
entity.setStripePriceId(domain.getStripePriceId());
entity.setStatus(mapStatusToEntity(domain.getStatus()));
entity.setCurrentPeriodStart(domain.getCurrentPeriodStart());
entity.setCurrentPeriodEnd(domain.getCurrentPeriodEnd());
entity.setCancelAtPeriodEnd(domain.getCancelAtPeriodEnd());
entity.setBillingCycle(mapBillingCycleToEntity(domain.getBillingCycle()));
entity.setNextBillingDate(domain.getNextBillingDate());
entity.setTrialEndDate(domain.getTrialEndDate());
entity.setUpdatedAt(domain.getUpdatedAt());
}
/**
* Convertit une liste d'entités en liste d'objets domaine
*/
public List<Subscription> toDomainList(List<SubscriptionEntity> entities) {
if (entities == null) {
return null;
}
return entities.stream()
.map(this::toDomain)
.collect(Collectors.toList());
}
/**
* Convertit une liste d'objets domaine en liste d'entités
*/
public List<SubscriptionEntity> toEntityList(List<Subscription> domains) {
if (domains == null) {
return null;
}
return domains.stream()
.map(this::toEntity)
.collect(Collectors.toList());
}
// ===== MÉTHODES DE MAPPING DES ÉNUMÉRATIONS =====
private SubscriptionStatus mapStatusToDomain(com.dh7789dev.xpeditis.entity.SubscriptionStatusEntity status) {
if (status == null) {
return null;
}
return switch (status) {
case INCOMPLETE -> SubscriptionStatus.INCOMPLETE;
case INCOMPLETE_EXPIRED -> SubscriptionStatus.INCOMPLETE_EXPIRED;
case TRIALING -> SubscriptionStatus.TRIALING;
case ACTIVE -> SubscriptionStatus.ACTIVE;
case PAST_DUE -> SubscriptionStatus.PAST_DUE;
case CANCELED -> SubscriptionStatus.CANCELED;
case UNPAID -> SubscriptionStatus.UNPAID;
case PAUSED -> SubscriptionStatus.PAUSED;
};
}
private com.dh7789dev.xpeditis.entity.SubscriptionStatusEntity mapStatusToEntity(SubscriptionStatus status) {
if (status == null) {
return null;
}
return switch (status) {
case INCOMPLETE -> com.dh7789dev.xpeditis.entity.SubscriptionStatusEntity.INCOMPLETE;
case INCOMPLETE_EXPIRED -> com.dh7789dev.xpeditis.entity.SubscriptionStatusEntity.INCOMPLETE_EXPIRED;
case TRIALING -> com.dh7789dev.xpeditis.entity.SubscriptionStatusEntity.TRIALING;
case ACTIVE -> com.dh7789dev.xpeditis.entity.SubscriptionStatusEntity.ACTIVE;
case PAST_DUE -> com.dh7789dev.xpeditis.entity.SubscriptionStatusEntity.PAST_DUE;
case CANCELED -> com.dh7789dev.xpeditis.entity.SubscriptionStatusEntity.CANCELED;
case UNPAID -> com.dh7789dev.xpeditis.entity.SubscriptionStatusEntity.UNPAID;
case PAUSED -> com.dh7789dev.xpeditis.entity.SubscriptionStatusEntity.PAUSED;
};
}
private BillingCycle mapBillingCycleToDomain(com.dh7789dev.xpeditis.entity.BillingCycleEntity billingCycle) {
if (billingCycle == null) {
return null;
}
return switch (billingCycle) {
case MONTHLY -> BillingCycle.MONTHLY;
case YEARLY -> BillingCycle.YEARLY;
};
}
private com.dh7789dev.xpeditis.entity.BillingCycleEntity mapBillingCycleToEntity(BillingCycle billingCycle) {
if (billingCycle == null) {
return null;
}
return switch (billingCycle) {
case MONTHLY -> com.dh7789dev.xpeditis.entity.BillingCycleEntity.MONTHLY;
case YEARLY -> com.dh7789dev.xpeditis.entity.BillingCycleEntity.YEARLY;
};
}
}

View File

@ -0,0 +1,332 @@
package com.dh7789dev.xpeditis.repository;
import com.dh7789dev.xpeditis.entity.InvoiceEntity;
import com.dh7789dev.xpeditis.entity.InvoiceStatusEntity;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
/**
* Repository JPA pour les factures
*/
@Repository
public interface InvoiceJpaRepository extends JpaRepository<InvoiceEntity, UUID> {
// ===== RECHERCHES DE BASE =====
/**
* Trouve une facture par son ID Stripe
*/
Optional<InvoiceEntity> findByStripeInvoiceId(String stripeInvoiceId);
/**
* Trouve une facture par son numéro
*/
Optional<InvoiceEntity> findByInvoiceNumber(String invoiceNumber);
/**
* Trouve les factures d'un abonnement
*/
List<InvoiceEntity> findBySubscriptionIdOrderByCreatedAtDesc(UUID subscriptionId);
/**
* Trouve les factures d'un abonnement avec pagination
*/
Page<InvoiceEntity> findBySubscriptionId(UUID subscriptionId, Pageable pageable);
/**
* Vérifie l'existence d'une facture par ID Stripe
*/
boolean existsByStripeInvoiceId(String stripeInvoiceId);
// ===== RECHERCHES PAR STATUT =====
/**
* Trouve les factures par statut
*/
List<InvoiceEntity> findByStatusOrderByCreatedAtDesc(InvoiceStatusEntity status);
/**
* Trouve les factures par statut avec pagination
*/
Page<InvoiceEntity> findByStatus(InvoiceStatusEntity status, Pageable pageable);
/**
* Trouve les factures ouvertes (en attente de paiement)
*/
@Query("SELECT i FROM InvoiceEntity i WHERE i.status = 'OPEN' ORDER BY i.dueDate ASC NULLS LAST")
List<InvoiceEntity> findOpenInvoices();
/**
* Trouve les factures payées
*/
@Query("SELECT i FROM InvoiceEntity i WHERE i.status = 'PAID' ORDER BY i.paidAt DESC")
List<InvoiceEntity> findPaidInvoices();
/**
* Trouve les factures échouées
*/
@Query("SELECT i FROM InvoiceEntity i WHERE i.status = 'PAYMENT_FAILED' ORDER BY i.createdAt DESC")
List<InvoiceEntity> findFailedInvoices();
// ===== RECHERCHES PAR DATE =====
/**
* Trouve les factures échues
*/
@Query("SELECT i FROM InvoiceEntity i WHERE i.dueDate < :now AND i.status = 'OPEN' ORDER BY i.dueDate ASC")
List<InvoiceEntity> findOverdueInvoices(@Param("now") LocalDateTime now);
/**
* Trouve les factures échues depuis plus de X jours
*/
@Query("SELECT i FROM InvoiceEntity i WHERE i.dueDate < :cutoffDate AND i.status = 'OPEN' ORDER BY i.dueDate ASC")
List<InvoiceEntity> findInvoicesOverdueSince(@Param("cutoffDate") LocalDateTime cutoffDate);
/**
* Trouve les factures avec échéance dans les X prochains jours
*/
@Query("SELECT i FROM InvoiceEntity i WHERE i.dueDate BETWEEN :now AND :endDate AND i.status = 'OPEN' ORDER BY i.dueDate ASC")
List<InvoiceEntity> findInvoicesDueSoon(@Param("now") LocalDateTime now, @Param("endDate") LocalDateTime endDate);
/**
* Trouve les factures créées dans une période
*/
List<InvoiceEntity> findByCreatedAtBetweenOrderByCreatedAtDesc(LocalDateTime startDate, LocalDateTime endDate);
/**
* Trouve les factures payées dans une période
*/
@Query("SELECT i FROM InvoiceEntity i WHERE i.paidAt BETWEEN :startDate AND :endDate ORDER BY i.paidAt DESC")
List<InvoiceEntity> findInvoicesPaidBetween(@Param("startDate") LocalDateTime startDate, @Param("endDate") LocalDateTime endDate);
// ===== RECHERCHES PAR PÉRIODE DE FACTURATION =====
/**
* Trouve les factures pour une période de facturation spécifique
*/
@Query("SELECT i FROM InvoiceEntity i WHERE i.billingPeriodStart = :periodStart AND i.billingPeriodEnd = :periodEnd")
List<InvoiceEntity> findByBillingPeriod(@Param("periodStart") LocalDateTime periodStart, @Param("periodEnd") LocalDateTime periodEnd);
/**
* Trouve les factures qui chevauchent une période donnée
*/
@Query("""
SELECT i FROM InvoiceEntity i
WHERE i.billingPeriodStart < :endDate
AND i.billingPeriodEnd > :startDate
ORDER BY i.billingPeriodStart ASC
""")
List<InvoiceEntity> findInvoicesOverlappingPeriod(@Param("startDate") LocalDateTime startDate, @Param("endDate") LocalDateTime endDate);
// ===== RECHERCHES PAR MONTANT =====
/**
* Trouve les factures par montant minimum
*/
List<InvoiceEntity> findByAmountDueGreaterThanEqualOrderByAmountDueDesc(BigDecimal minAmount);
/**
* Trouve les factures dans une fourchette de montants
*/
List<InvoiceEntity> findByAmountDueBetweenOrderByAmountDueDesc(BigDecimal minAmount, BigDecimal maxAmount);
/**
* Trouve les petites factures (moins de X euros)
*/
@Query("SELECT i FROM InvoiceEntity i WHERE i.amountDue < :maxAmount ORDER BY i.amountDue ASC")
List<InvoiceEntity> findSmallInvoices(@Param("maxAmount") BigDecimal maxAmount);
/**
* Trouve les grosses factures (plus de X euros)
*/
@Query("SELECT i FROM InvoiceEntity i WHERE i.amountDue > :minAmount ORDER BY i.amountDue DESC")
List<InvoiceEntity> findLargeInvoices(@Param("minAmount") BigDecimal minAmount);
// ===== RECHERCHES PAR TENTATIVES DE PAIEMENT =====
/**
* Trouve les factures avec plusieurs tentatives d'échec
*/
@Query("SELECT i FROM InvoiceEntity i WHERE i.attemptCount > :minAttempts ORDER BY i.attemptCount DESC")
List<InvoiceEntity> findInvoicesWithMultipleAttempts(@Param("minAttempts") int minAttempts);
/**
* Trouve les factures nécessitant une attention (échouées plusieurs fois)
*/
@Query("SELECT i FROM InvoiceEntity i WHERE i.attemptCount >= 3 AND i.status IN ('OPEN', 'PAYMENT_FAILED') ORDER BY i.attemptCount DESC")
List<InvoiceEntity> findInvoicesRequiringAttention();
// ===== RECHERCHES PAR ABONNEMENT =====
/**
* Trouve la dernière facture d'un abonnement
*/
@Query("SELECT i FROM InvoiceEntity i WHERE i.subscription.id = :subscriptionId ORDER BY i.createdAt DESC LIMIT 1")
Optional<InvoiceEntity> findLatestInvoiceBySubscription(@Param("subscriptionId") UUID subscriptionId);
/**
* Trouve la première facture d'un abonnement
*/
@Query("SELECT i FROM InvoiceEntity i WHERE i.subscription.id = :subscriptionId ORDER BY i.createdAt ASC LIMIT 1")
Optional<InvoiceEntity> findFirstInvoiceBySubscription(@Param("subscriptionId") UUID subscriptionId);
/**
* Compte les factures d'un abonnement
*/
long countBySubscriptionId(UUID subscriptionId);
/**
* Trouve les factures impayées d'un abonnement
*/
@Query("SELECT i FROM InvoiceEntity i WHERE i.subscription.id = :subscriptionId AND i.status IN ('OPEN', 'PAYMENT_FAILED') ORDER BY i.dueDate ASC")
List<InvoiceEntity> findUnpaidInvoicesBySubscription(@Param("subscriptionId") UUID subscriptionId);
// ===== STATISTIQUES ET MÉTRIQUES =====
/**
* Compte les factures par statut
*/
long countByStatus(InvoiceStatusEntity status);
/**
* Calcule le montant total facturé dans une période
*/
@Query("SELECT COALESCE(SUM(i.amountDue), 0) FROM InvoiceEntity i WHERE i.createdAt BETWEEN :startDate AND :endDate")
BigDecimal calculateTotalInvoicedBetween(@Param("startDate") LocalDateTime startDate, @Param("endDate") LocalDateTime endDate);
/**
* Calcule le montant total payé dans une période
*/
@Query("SELECT COALESCE(SUM(i.amountPaid), 0) FROM InvoiceEntity i WHERE i.paidAt BETWEEN :startDate AND :endDate")
BigDecimal calculateTotalPaidBetween(@Param("startDate") LocalDateTime startDate, @Param("endDate") LocalDateTime endDate);
/**
* Calcule le montant total en attente
*/
@Query("SELECT COALESCE(SUM(i.amountDue - COALESCE(i.amountPaid, 0)), 0) FROM InvoiceEntity i WHERE i.status = 'OPEN'")
BigDecimal calculateTotalOutstanding();
/**
* Calcule le montant total en retard
*/
@Query("SELECT COALESCE(SUM(i.amountDue - COALESCE(i.amountPaid, 0)), 0) FROM InvoiceEntity i WHERE i.status = 'OPEN' AND i.dueDate < :now")
BigDecimal calculateTotalOverdue(@Param("now") LocalDateTime now);
/**
* Calcule le taux de paiement (pourcentage de factures payées)
*/
@Query("""
SELECT CAST(
COUNT(CASE WHEN i.status = 'PAID' THEN 1 END) * 100.0 /
NULLIF(COUNT(i), 0)
AS DOUBLE
)
FROM InvoiceEntity i
WHERE i.createdAt BETWEEN :startDate AND :endDate
""")
Double calculatePaymentRate(@Param("startDate") LocalDateTime startDate, @Param("endDate") LocalDateTime endDate);
// ===== RAPPORTS =====
/**
* Rapport des revenus par mois
*/
@Query("""
SELECT
YEAR(i.paidAt) as year,
MONTH(i.paidAt) as month,
COUNT(i) as invoiceCount,
SUM(i.amountPaid) as totalRevenue
FROM InvoiceEntity i
WHERE i.status = 'PAID'
AND i.paidAt BETWEEN :startDate AND :endDate
GROUP BY YEAR(i.paidAt), MONTH(i.paidAt)
ORDER BY year DESC, month DESC
""")
List<Object[]> getMonthlyRevenueReport(@Param("startDate") LocalDateTime startDate, @Param("endDate") LocalDateTime endDate);
/**
* Top des abonnements par revenus
*/
@Query("""
SELECT
i.subscription.stripeSubscriptionId,
COUNT(i) as invoiceCount,
SUM(i.amountPaid) as totalRevenue
FROM InvoiceEntity i
WHERE i.status = 'PAID'
GROUP BY i.subscription.id, i.subscription.stripeSubscriptionId
ORDER BY totalRevenue DESC
""")
List<Object[]> getTopSubscriptionsByRevenue(Pageable pageable);
// ===== OPÉRATIONS DE MAINTENANCE =====
/**
* Met à jour les factures échues
*/
@Modifying
@Query("UPDATE InvoiceEntity i SET i.attemptCount = i.attemptCount + 1 WHERE i.dueDate < :now AND i.status = 'OPEN'")
int incrementAttemptsForOverdueInvoices(@Param("now") LocalDateTime now);
/**
* Marque les factures anciennes comme irrécouvrables
*/
@Modifying
@Query("UPDATE InvoiceEntity i SET i.status = 'UNCOLLECTIBLE' WHERE i.dueDate < :cutoffDate AND i.status = 'OPEN' AND i.attemptCount > :maxAttempts")
int markOldInvoicesAsUncollectible(@Param("cutoffDate") LocalDateTime cutoffDate, @Param("maxAttempts") int maxAttempts);
/**
* Archive les anciennes factures payées
*/
@Modifying
@Query("DELETE FROM InvoiceEntity i WHERE i.status = 'PAID' AND i.paidAt < :cutoffDate")
int deleteOldPaidInvoices(@Param("cutoffDate") LocalDateTime cutoffDate);
// ===== RECHERCHES AVANCÉES =====
/**
* Recherche full-text dans les factures
*/
@Query("""
SELECT i FROM InvoiceEntity i
WHERE i.invoiceNumber LIKE %:searchTerm%
OR i.stripeInvoiceId LIKE %:searchTerm%
OR i.subscription.stripeSubscriptionId LIKE %:searchTerm%
ORDER BY i.createdAt DESC
""")
List<InvoiceEntity> searchInvoices(@Param("searchTerm") String searchTerm);
/**
* Trouve les factures par multiple critères
*/
@Query("""
SELECT i FROM InvoiceEntity i
WHERE (:status IS NULL OR i.status = :status)
AND (:subscriptionId IS NULL OR i.subscription.id = :subscriptionId)
AND (:minAmount IS NULL OR i.amountDue >= :minAmount)
AND (:maxAmount IS NULL OR i.amountDue <= :maxAmount)
AND (:startDate IS NULL OR i.createdAt >= :startDate)
AND (:endDate IS NULL OR i.createdAt <= :endDate)
ORDER BY i.createdAt DESC
""")
List<InvoiceEntity> findByMultipleCriteria(
@Param("status") InvoiceStatusEntity status,
@Param("subscriptionId") UUID subscriptionId,
@Param("minAmount") BigDecimal minAmount,
@Param("maxAmount") BigDecimal maxAmount,
@Param("startDate") LocalDateTime startDate,
@Param("endDate") LocalDateTime endDate
);
}

View File

@ -0,0 +1,333 @@
package com.dh7789dev.xpeditis.repository;
import com.dh7789dev.xpeditis.dto.app.EventStatus;
import com.dh7789dev.xpeditis.entity.PaymentEventEntity;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
/**
* Repository JPA pour les événements webhook de paiement
*/
@Repository
public interface PaymentEventJpaRepository extends JpaRepository<PaymentEventEntity, UUID> {
// ===== RECHERCHES DE BASE =====
/**
* Trouve un événement par son ID Stripe
*/
Optional<PaymentEventEntity> findByStripeEventId(String stripeEventId);
/**
* Trouve les événements par type
*/
List<PaymentEventEntity> findByEventTypeOrderByCreatedAtDesc(String eventType);
/**
* Trouve les événements par type avec limitation
*/
List<PaymentEventEntity> findByEventTypeOrderByCreatedAtDesc(String eventType, Pageable pageable);
/**
* Vérifie l'existence d'un événement par ID Stripe
*/
boolean existsByStripeEventId(String stripeEventId);
/**
* Vérifie l'existence et le statut d'un événement
*/
boolean existsByStripeEventIdAndStatus(String stripeEventId, EventStatus status);
// ===== RECHERCHES PAR STATUT =====
/**
* Trouve les événements par statut triés par date de création
*/
List<PaymentEventEntity> findByStatusOrderByCreatedAtAsc(EventStatus status);
/**
* Trouve les événements par statut triés par date de création (desc)
*/
List<PaymentEventEntity> findByStatusOrderByCreatedAtDesc(EventStatus status);
/**
* Trouve les événements en attente
*/
@Query("SELECT e FROM PaymentEventEntity e WHERE e.status = 'PENDING' ORDER BY e.createdAt ASC")
List<PaymentEventEntity> findPendingEvents();
/**
* Trouve les événements échoués qui peuvent être retentés
*/
@Query("SELECT e FROM PaymentEventEntity e WHERE e.status = 'FAILED' AND e.retryCount < :maxRetries ORDER BY e.createdAt ASC")
List<PaymentEventEntity> findFailedEventsForRetry(@Param("maxRetries") int maxRetries);
/**
* Trouve les événements en cours de traitement depuis trop longtemps
*/
@Query("SELECT e FROM PaymentEventEntity e WHERE e.status = 'PROCESSING' AND e.createdAt < :cutoffTime ORDER BY e.createdAt ASC")
List<PaymentEventEntity> findStuckProcessingEvents(@Param("cutoffTime") LocalDateTime cutoffTime);
// ===== RECHERCHES PAR ABONNEMENT =====
/**
* Trouve les événements liés à un abonnement
*/
List<PaymentEventEntity> findBySubscriptionIdOrderByCreatedAtDesc(UUID subscriptionId);
/**
* Trouve les événements récents d'un abonnement
*/
@Query("SELECT e FROM PaymentEventEntity e WHERE e.subscription.id = :subscriptionId AND e.createdAt > :since ORDER BY e.createdAt DESC")
List<PaymentEventEntity> findRecentEventsBySubscription(@Param("subscriptionId") UUID subscriptionId, @Param("since") LocalDateTime since);
// ===== RECHERCHES PAR DATE =====
/**
* Trouve les événements créés dans une période
*/
List<PaymentEventEntity> findByCreatedAtBetweenOrderByCreatedAtDesc(LocalDateTime startDate, LocalDateTime endDate);
/**
* Trouve les événements traités dans une période
*/
@Query("SELECT e FROM PaymentEventEntity e WHERE e.processedAt BETWEEN :startDate AND :endDate ORDER BY e.processedAt DESC")
List<PaymentEventEntity> findProcessedEventsBetween(@Param("startDate") LocalDateTime startDate, @Param("endDate") LocalDateTime endDate);
/**
* Trouve les anciens événements traités
*/
@Query("SELECT e FROM PaymentEventEntity e WHERE e.status = 'PROCESSED' AND e.processedAt < :cutoffDate ORDER BY e.processedAt ASC")
List<PaymentEventEntity> findOldProcessedEvents(@Param("cutoffDate") LocalDateTime cutoffDate);
// ===== RECHERCHES PAR TYPE D'ÉVÉNEMENT =====
/**
* Trouve les événements de facturation
*/
@Query("SELECT e FROM PaymentEventEntity e WHERE e.eventType LIKE 'invoice.%' ORDER BY e.createdAt DESC")
List<PaymentEventEntity> findInvoiceEvents();
/**
* Trouve les événements d'abonnement
*/
@Query("SELECT e FROM PaymentEventEntity e WHERE e.eventType LIKE 'customer.subscription.%' ORDER BY e.createdAt DESC")
List<PaymentEventEntity> findSubscriptionEvents();
/**
* Trouve les événements de paiement
*/
@Query("SELECT e FROM PaymentEventEntity e WHERE e.eventType LIKE 'payment_%' OR e.eventType LIKE 'invoice.payment%' ORDER BY e.createdAt DESC")
List<PaymentEventEntity> findPaymentEvents();
/**
* Trouve les événements par multiple types
*/
List<PaymentEventEntity> findByEventTypeIn(List<String> eventTypes, org.springframework.data.domain.Sort sort);
/**
* Trouve les événements par types et statuts
*/
@Query("SELECT e FROM PaymentEventEntity e WHERE e.eventType IN :eventTypes AND e.status IN :statuses ORDER BY e.createdAt DESC")
List<PaymentEventEntity> findByEventTypeInAndStatusIn(@Param("eventTypes") List<String> eventTypes, @Param("statuses") List<EventStatus> statuses, org.springframework.data.domain.Sort sort);
// ===== RECHERCHES PAR TENTATIVES =====
/**
* Trouve les événements avec beaucoup de tentatives
*/
@Query("SELECT e FROM PaymentEventEntity e WHERE e.retryCount > :minRetries ORDER BY e.retryCount DESC")
List<PaymentEventEntity> findEventsWithManyRetries(@Param("minRetries") int minRetries);
/**
* Trouve les événements ayant atteint le maximum de tentatives
*/
@Query("SELECT e FROM PaymentEventEntity e WHERE e.retryCount >= :maxRetries AND e.status = 'FAILED' ORDER BY e.createdAt ASC")
List<PaymentEventEntity> findEventsAtMaxRetries(@Param("maxRetries") int maxRetries);
// ===== RECHERCHES PROBLÉMATIQUES =====
/**
* Trouve les événements problématiques nécessitant une attention
*/
@Query("""
SELECT e FROM PaymentEventEntity e
WHERE (e.status = 'PROCESSING' AND e.createdAt < :stuckThreshold)
OR (e.status = 'PENDING' AND e.createdAt < :oldPendingThreshold)
OR (e.status = 'FAILED' AND e.retryCount > :highRetryCount)
ORDER BY e.createdAt ASC
""")
List<PaymentEventEntity> findProblematicEvents(
@Param("stuckThreshold") LocalDateTime stuckThreshold,
@Param("oldPendingThreshold") LocalDateTime oldPendingThreshold,
@Param("highRetryCount") int highRetryCount
);
// ===== RECHERCHES AVANCÉES AVEC CRITÈRES MULTIPLES =====
/**
* Trouve les événements par multiples statuts
*/
List<PaymentEventEntity> findByStatusIn(List<EventStatus> statuses, org.springframework.data.domain.Sort sort);
/**
* Recherche full-text dans les événements
*/
@Query("""
SELECT e FROM PaymentEventEntity e
WHERE e.stripeEventId LIKE %:searchTerm%
OR e.eventType LIKE %:searchTerm%
OR e.errorMessage LIKE %:searchTerm%
OR (e.subscription IS NOT NULL AND e.subscription.stripeSubscriptionId LIKE %:searchTerm%)
ORDER BY e.createdAt DESC
""")
List<PaymentEventEntity> searchEvents(@Param("searchTerm") String searchTerm);
// ===== STATISTIQUES =====
/**
* Compte les événements par statut
*/
long countByStatus(EventStatus status);
/**
* Compte les événements traités depuis une date
*/
long countByStatusAndProcessedAtAfter(EventStatus status, LocalDateTime since);
/**
* Compte les événements créés depuis une date
*/
long countByStatusAndCreatedAtAfter(EventStatus status, LocalDateTime since);
/**
* Calcule le temps de traitement moyen
*/
@Query("""
SELECT AVG(TIMESTAMPDIFF(SECOND, e.createdAt, e.processedAt))
FROM PaymentEventEntity e
WHERE e.status = 'PROCESSED'
AND e.processedAt > :since
AND e.processedAt IS NOT NULL
""")
Double calculateAverageProcessingTime(@Param("since") LocalDateTime since);
/**
* Trouve le nombre maximum de tentatives
*/
@Query("SELECT MAX(e.retryCount) FROM PaymentEventEntity e WHERE e.createdAt > :since")
Long findMaxRetryCount(@Param("since") LocalDateTime since);
// ===== RAPPORTS D'ERREURS =====
/**
* Trouve les erreurs les plus fréquentes
*/
@Query("""
SELECT e.errorMessage, COUNT(e) as errorCount
FROM PaymentEventEntity e
WHERE e.status = 'FAILED'
AND e.createdAt > :since
AND e.errorMessage IS NOT NULL
GROUP BY e.errorMessage
ORDER BY errorCount DESC
""")
List<String> findMostFrequentErrors(@Param("since") LocalDateTime since, Pageable pageable);
/**
* Trouve les types d'événements les plus lents
*/
@Query("""
SELECT e.eventType, AVG(TIMESTAMPDIFF(SECOND, e.createdAt, e.processedAt)) as avgProcessingTime
FROM PaymentEventEntity e
WHERE e.status = 'PROCESSED'
AND e.processedAt > :since
AND e.processedAt IS NOT NULL
GROUP BY e.eventType
ORDER BY avgProcessingTime DESC
""")
List<String> findSlowestEventTypes(@Param("since") LocalDateTime since, Pageable pageable);
// ===== OPÉRATIONS DE MAINTENANCE =====
/**
* Supprime les anciens événements traités
*/
@Modifying
@Query("DELETE FROM PaymentEventEntity e WHERE e.status = 'PROCESSED' AND e.processedAt < :cutoffDate")
int deleteByStatusAndProcessedAtBefore(EventStatus status, LocalDateTime cutoffDate);
/**
* Supprime les anciens événements échoués
*/
@Modifying
@Query("DELETE FROM PaymentEventEntity e WHERE e.status = 'FAILED' AND e.retryCount > :maxRetries AND e.createdAt < :cutoffDate")
int deleteFailedEventsOlderThan(@Param("cutoffDate") LocalDateTime cutoffDate, @Param("maxRetries") int maxRetries);
/**
* Supprime les événements anciens (archivage)
*/
@Modifying
@Query("DELETE FROM PaymentEventEntity e WHERE e.createdAt < :cutoffDate")
int deleteByCreatedAtBefore(@Param("cutoffDate") LocalDateTime cutoffDate);
/**
* Remet à zéro les événements bloqués en traitement
*/
@Modifying
@Query("UPDATE PaymentEventEntity e SET e.status = 'PENDING' WHERE e.status = 'PROCESSING' AND e.createdAt < :cutoffTime")
int resetStuckProcessingEvents(@Param("cutoffTime") LocalDateTime cutoffTime);
// ===== RAPPORTS ET ANALYTICS =====
/**
* Rapport des événements par type et période
*/
@Query("""
SELECT e.eventType, COUNT(e) as eventCount, e.status
FROM PaymentEventEntity e
WHERE e.createdAt BETWEEN :startDate AND :endDate
GROUP BY e.eventType, e.status
ORDER BY eventCount DESC
""")
List<Object[]> getEventReport(@Param("startDate") LocalDateTime startDate, @Param("endDate") LocalDateTime endDate);
/**
* Rapport des performances de traitement
*/
@Query("""
SELECT
DATE(e.createdAt) as eventDate,
COUNT(e) as totalEvents,
COUNT(CASE WHEN e.status = 'PROCESSED' THEN 1 END) as processedEvents,
COUNT(CASE WHEN e.status = 'FAILED' THEN 1 END) as failedEvents,
AVG(CASE WHEN e.processedAt IS NOT NULL THEN TIMESTAMPDIFF(SECOND, e.createdAt, e.processedAt) END) as avgProcessingTime
FROM PaymentEventEntity e
WHERE e.createdAt BETWEEN :startDate AND :endDate
GROUP BY DATE(e.createdAt)
ORDER BY eventDate DESC
""")
List<Object[]> getProcessingPerformanceReport(@Param("startDate") LocalDateTime startDate, @Param("endDate") LocalDateTime endDate);
/**
* Top des abonnements générant le plus d'événements
*/
@Query("""
SELECT e.subscription.stripeSubscriptionId, COUNT(e) as eventCount
FROM PaymentEventEntity e
WHERE e.subscription IS NOT NULL
AND e.createdAt > :since
GROUP BY e.subscription.id, e.subscription.stripeSubscriptionId
ORDER BY eventCount DESC
""")
List<Object[]> getTopEventGeneratingSubscriptions(@Param("since") LocalDateTime since, Pageable pageable);
}

View File

@ -0,0 +1,371 @@
package com.dh7789dev.xpeditis.repository;
import com.dh7789dev.xpeditis.entity.PaymentMethodEntity;
import com.dh7789dev.xpeditis.entity.PaymentMethodTypeEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
/**
* Repository JPA pour les méthodes de paiement
*/
@Repository
public interface PaymentMethodJpaRepository extends JpaRepository<PaymentMethodEntity, UUID> {
// ===== RECHERCHES DE BASE =====
/**
* Trouve une méthode de paiement par son ID Stripe
*/
Optional<PaymentMethodEntity> findByStripePaymentMethodId(String stripePaymentMethodId);
/**
* Trouve toutes les méthodes de paiement d'une entreprise
*/
List<PaymentMethodEntity> findByCompanyIdOrderByCreatedAtDesc(UUID companyId);
/**
* Trouve les méthodes de paiement actives d'une entreprise
*/
@Query("SELECT pm FROM PaymentMethodEntity pm WHERE pm.companyId = :companyId ORDER BY pm.isDefault DESC, pm.createdAt DESC")
List<PaymentMethodEntity> findActivePaymentMethodsByCompany(@Param("companyId") UUID companyId);
/**
* Vérifie l'existence d'une méthode de paiement par ID Stripe
*/
boolean existsByStripePaymentMethodId(String stripePaymentMethodId);
// ===== RECHERCHES PAR TYPE =====
/**
* Trouve les méthodes de paiement par type
*/
List<PaymentMethodEntity> findByTypeOrderByCreatedAtDesc(PaymentMethodTypeEntity type);
/**
* Trouve les méthodes de paiement par type pour une entreprise
*/
List<PaymentMethodEntity> findByCompanyIdAndTypeOrderByCreatedAtDesc(UUID companyId, PaymentMethodTypeEntity type);
/**
* Trouve toutes les cartes de crédit
*/
@Query("SELECT pm FROM PaymentMethodEntity pm WHERE pm.type = 'CARD' ORDER BY pm.createdAt DESC")
List<PaymentMethodEntity> findAllCards();
/**
* Trouve les cartes d'une entreprise
*/
@Query("SELECT pm FROM PaymentMethodEntity pm WHERE pm.companyId = :companyId AND pm.type = 'CARD' ORDER BY pm.isDefault DESC, pm.createdAt DESC")
List<PaymentMethodEntity> findCardsByCompany(@Param("companyId") UUID companyId);
/**
* Trouve les méthodes de prélèvement SEPA
*/
@Query("SELECT pm FROM PaymentMethodEntity pm WHERE pm.type = 'SEPA_DEBIT' ORDER BY pm.createdAt DESC")
List<PaymentMethodEntity> findSepaDebitMethods();
// ===== RECHERCHES PAR DÉFAUT =====
/**
* Trouve la méthode de paiement par défaut d'une entreprise
*/
Optional<PaymentMethodEntity> findByCompanyIdAndIsDefaultTrue(UUID companyId);
/**
* Trouve toutes les méthodes par défaut (pour debug/maintenance)
*/
@Query("SELECT pm FROM PaymentMethodEntity pm WHERE pm.isDefault = true ORDER BY pm.companyId, pm.createdAt DESC")
List<PaymentMethodEntity> findAllDefaultPaymentMethods();
/**
* Compte les méthodes par défaut par entreprise (devrait être 0 ou 1)
*/
long countByCompanyIdAndIsDefaultTrue(UUID companyId);
// ===== RECHERCHES PAR EXPIRATION =====
/**
* Trouve les cartes expirant bientôt
*/
@Query("""
SELECT pm FROM PaymentMethodEntity pm
WHERE pm.type = 'CARD'
AND pm.cardExpYear IS NOT NULL
AND pm.cardExpMonth IS NOT NULL
AND (
pm.cardExpYear < :currentYear
OR (pm.cardExpYear = :currentYear AND pm.cardExpMonth <= :currentMonth + :monthsAhead)
)
ORDER BY pm.cardExpYear ASC, pm.cardExpMonth ASC
""")
List<PaymentMethodEntity> findCardsExpiringSoon(
@Param("currentYear") int currentYear,
@Param("currentMonth") int currentMonth,
@Param("monthsAhead") int monthsAhead
);
/**
* Trouve les cartes expirées
*/
@Query("""
SELECT pm FROM PaymentMethodEntity pm
WHERE pm.type = 'CARD'
AND pm.cardExpYear IS NOT NULL
AND pm.cardExpMonth IS NOT NULL
AND (
pm.cardExpYear < :currentYear
OR (pm.cardExpYear = :currentYear AND pm.cardExpMonth < :currentMonth)
)
ORDER BY pm.cardExpYear DESC, pm.cardExpMonth DESC
""")
List<PaymentMethodEntity> findExpiredCards(
@Param("currentYear") int currentYear,
@Param("currentMonth") int currentMonth
);
/**
* Trouve les cartes d'une entreprise expirant dans les X mois
*/
@Query("""
SELECT pm FROM PaymentMethodEntity pm
WHERE pm.companyId = :companyId
AND pm.type = 'CARD'
AND pm.cardExpYear IS NOT NULL
AND pm.cardExpMonth IS NOT NULL
AND (
pm.cardExpYear < :currentYear
OR (pm.cardExpYear = :currentYear AND pm.cardExpMonth <= :currentMonth + :monthsAhead)
)
ORDER BY pm.cardExpYear ASC, pm.cardExpMonth ASC
""")
List<PaymentMethodEntity> findCompanyCardsExpiringSoon(
@Param("companyId") UUID companyId,
@Param("currentYear") int currentYear,
@Param("currentMonth") int currentMonth,
@Param("monthsAhead") int monthsAhead
);
// ===== RECHERCHES PAR MARQUE DE CARTE =====
/**
* Trouve les cartes par marque
*/
List<PaymentMethodEntity> findByCardBrandIgnoreCaseOrderByCreatedAtDesc(String cardBrand);
/**
* Trouve les cartes d'une entreprise par marque
*/
List<PaymentMethodEntity> findByCompanyIdAndCardBrandIgnoreCaseOrderByCreatedAtDesc(UUID companyId, String cardBrand);
/**
* Trouve les cartes par les 4 derniers chiffres
*/
List<PaymentMethodEntity> findByCardLast4OrderByCreatedAtDesc(String cardLast4);
/**
* Trouve une carte spécifique d'une entreprise par marque et 4 derniers chiffres
*/
Optional<PaymentMethodEntity> findByCompanyIdAndCardBrandIgnoreCaseAndCardLast4(
UUID companyId, String cardBrand, String cardLast4
);
// ===== RECHERCHES PAR BANQUE =====
/**
* Trouve les méthodes de paiement par banque
*/
List<PaymentMethodEntity> findByBankNameIgnoreCaseOrderByCreatedAtDesc(String bankName);
/**
* Trouve les méthodes d'une entreprise par banque
*/
List<PaymentMethodEntity> findByCompanyIdAndBankNameIgnoreCaseOrderByCreatedAtDesc(UUID companyId, String bankName);
// ===== STATISTIQUES =====
/**
* Compte les méthodes de paiement par entreprise
*/
long countByCompanyId(UUID companyId);
/**
* Compte les méthodes de paiement par type
*/
long countByType(PaymentMethodTypeEntity type);
/**
* Compte les cartes par marque
*/
long countByCardBrandIgnoreCase(String cardBrand);
/**
* Statistiques par type de méthode de paiement
*/
@Query("""
SELECT pm.type, COUNT(pm) as methodCount
FROM PaymentMethodEntity pm
GROUP BY pm.type
ORDER BY methodCount DESC
""")
List<Object[]> getPaymentMethodStatistics();
/**
* Statistiques par marque de carte
*/
@Query("""
SELECT pm.cardBrand, COUNT(pm) as cardCount
FROM PaymentMethodEntity pm
WHERE pm.type = 'CARD' AND pm.cardBrand IS NOT NULL
GROUP BY pm.cardBrand
ORDER BY cardCount DESC
""")
List<Object[]> getCardBrandStatistics();
// ===== RAPPORTS =====
/**
* Rapport des méthodes de paiement par entreprise
*/
@Query("""
SELECT pm.companyId, pm.type, COUNT(pm) as methodCount
FROM PaymentMethodEntity pm
GROUP BY pm.companyId, pm.type
ORDER BY pm.companyId, methodCount DESC
""")
List<Object[]> getPaymentMethodsByCompanyReport();
/**
* Rapport des expirations de cartes par mois
*/
@Query("""
SELECT pm.cardExpYear, pm.cardExpMonth, COUNT(pm) as expiringCards
FROM PaymentMethodEntity pm
WHERE pm.type = 'CARD'
AND pm.cardExpYear IS NOT NULL
AND pm.cardExpMonth IS NOT NULL
GROUP BY pm.cardExpYear, pm.cardExpMonth
ORDER BY pm.cardExpYear ASC, pm.cardExpMonth ASC
""")
List<Object[]> getCardExpirationReport();
// ===== OPÉRATIONS DE MAINTENANCE =====
/**
* Met à jour les méthodes par défaut d'une entreprise (retire le statut de défaut)
*/
@Modifying
@Query("UPDATE PaymentMethodEntity pm SET pm.isDefault = false WHERE pm.companyId = :companyId AND pm.isDefault = true")
int removeAllDefaultsForCompany(@Param("companyId") UUID companyId);
/**
* Définit une méthode comme méthode par défaut (et retire le statut des autres)
*/
@Modifying
@Query("UPDATE PaymentMethodEntity pm SET pm.isDefault = CASE WHEN pm.id = :paymentMethodId THEN true ELSE false END WHERE pm.companyId = :companyId")
int setAsDefaultPaymentMethod(@Param("companyId") UUID companyId, @Param("paymentMethodId") UUID paymentMethodId);
/**
* Supprime les anciennes méthodes de paiement non utilisées
*/
@Modifying
@Query("DELETE FROM PaymentMethodEntity pm WHERE pm.createdAt < :cutoffDate AND pm.isDefault = false")
int deleteOldUnusedPaymentMethods(@Param("cutoffDate") LocalDateTime cutoffDate);
// ===== VALIDATION ET VÉRIFICATIONS =====
/**
* Trouve les entreprises avec plusieurs méthodes par défaut (problème de données)
*/
@Query("""
SELECT pm.companyId, COUNT(pm) as defaultCount
FROM PaymentMethodEntity pm
WHERE pm.isDefault = true
GROUP BY pm.companyId
HAVING COUNT(pm) > 1
""")
List<Object[]> findCompaniesWithMultipleDefaults();
/**
* Trouve les méthodes de paiement orphelines (entreprise inexistante)
*/
@Query("""
SELECT pm FROM PaymentMethodEntity pm
WHERE pm.companyId NOT IN (SELECT c.id FROM Company c)
""")
List<PaymentMethodEntity> findOrphanedPaymentMethods();
/**
* Trouve les entreprises sans méthode de paiement
*/
@Query("""
SELECT c.id, c.name FROM Company c
WHERE c.id NOT IN (SELECT DISTINCT pm.companyId FROM PaymentMethodEntity pm)
""")
List<Object[]> findCompaniesWithoutPaymentMethods();
// ===== RECHERCHES AVANCÉES =====
/**
* Recherche full-text dans les méthodes de paiement
*/
@Query("""
SELECT pm FROM PaymentMethodEntity pm
WHERE pm.stripePaymentMethodId LIKE %:searchTerm%
OR LOWER(pm.cardBrand) LIKE LOWER(CONCAT('%', :searchTerm, '%'))
OR pm.cardLast4 LIKE %:searchTerm%
OR LOWER(pm.bankName) LIKE LOWER(CONCAT('%', :searchTerm, '%'))
ORDER BY pm.createdAt DESC
""")
List<PaymentMethodEntity> searchPaymentMethods(@Param("searchTerm") String searchTerm);
/**
* Trouve les méthodes de paiement par multiple critères
*/
@Query("""
SELECT pm FROM PaymentMethodEntity pm
WHERE (:companyId IS NULL OR pm.companyId = :companyId)
AND (:type IS NULL OR pm.type = :type)
AND (:isDefault IS NULL OR pm.isDefault = :isDefault)
AND (:cardBrand IS NULL OR LOWER(pm.cardBrand) = LOWER(:cardBrand))
ORDER BY pm.isDefault DESC, pm.createdAt DESC
""")
List<PaymentMethodEntity> findByMultipleCriteria(
@Param("companyId") UUID companyId,
@Param("type") PaymentMethodTypeEntity type,
@Param("isDefault") Boolean isDefault,
@Param("cardBrand") String cardBrand
);
/**
* Trouve les méthodes de paiement supportant les paiements récurrents
*/
@Query("SELECT pm FROM PaymentMethodEntity pm WHERE pm.type IN ('CARD', 'SEPA_DEBIT') ORDER BY pm.isDefault DESC, pm.createdAt DESC")
List<PaymentMethodEntity> findRecurringPaymentCapableMethods();
/**
* Trouve les méthodes de paiement nécessitant une attention (expirées ou expirant bientôt)
*/
@Query("""
SELECT pm FROM PaymentMethodEntity pm
WHERE pm.type = 'CARD'
AND pm.cardExpYear IS NOT NULL
AND pm.cardExpMonth IS NOT NULL
AND (
pm.cardExpYear < :currentYear
OR (pm.cardExpYear = :currentYear AND pm.cardExpMonth <= :currentMonth + 2)
)
ORDER BY pm.isDefault DESC, pm.cardExpYear ASC, pm.cardExpMonth ASC
""")
List<PaymentMethodEntity> findPaymentMethodsRequiringAttention(
@Param("currentYear") int currentYear,
@Param("currentMonth") int currentMonth
);
}

View File

@ -0,0 +1,285 @@
package com.dh7789dev.xpeditis.repository;
import com.dh7789dev.xpeditis.entity.PlanEntity;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.math.BigDecimal;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
/**
* Repository JPA pour les plans d'abonnement
*/
@Repository
public interface PlanJpaRepository extends JpaRepository<PlanEntity, UUID> {
// ===== RECHERCHES DE BASE =====
/**
* Trouve un plan par son nom
*/
Optional<PlanEntity> findByName(String name);
/**
* Trouve un plan par son type
*/
Optional<PlanEntity> findByType(String type);
/**
* Trouve un plan par son ID de prix Stripe (mensuel)
*/
Optional<PlanEntity> findByStripePriceIdMonthly(String stripePriceIdMonthly);
/**
* Trouve un plan par son ID de prix Stripe (annuel)
*/
Optional<PlanEntity> findByStripePriceIdYearly(String stripePriceIdYearly);
/**
* Trouve un plan par l'un de ses ID de prix Stripe
*/
@Query("SELECT p FROM PlanEntity p WHERE p.stripePriceIdMonthly = :priceId OR p.stripePriceIdYearly = :priceId")
Optional<PlanEntity> findByAnyStripePriceId(@Param("priceId") String priceId);
/**
* Vérifie l'existence d'un plan par nom
*/
boolean existsByName(String name);
/**
* Vérifie l'existence d'un plan par type
*/
boolean existsByType(String type);
// ===== RECHERCHES PAR STATUT =====
/**
* Trouve tous les plans actifs ordonnés par ordre d'affichage
*/
List<PlanEntity> findByIsActiveTrueOrderByDisplayOrderAsc();
/**
* Trouve tous les plans disponibles pour la souscription
*/
@Query("SELECT p FROM PlanEntity p WHERE p.isActive = true ORDER BY p.displayOrder ASC, p.monthlyPrice ASC")
List<PlanEntity> findAvailablePlans();
/**
* Trouve tous les plans inactifs
*/
List<PlanEntity> findByIsActiveFalseOrderByNameAsc();
/**
* Trouve les plans actifs avec pagination
*/
Page<PlanEntity> findByIsActiveTrue(Pageable pageable);
// ===== RECHERCHES PAR PRIX =====
/**
* Trouve les plans dans une fourchette de prix mensuel
*/
List<PlanEntity> findByMonthlyPriceBetweenAndIsActiveTrueOrderByMonthlyPriceAsc(BigDecimal minPrice, BigDecimal maxPrice);
/**
* Trouve les plans dans une fourchette de prix annuel
*/
List<PlanEntity> findByYearlyPriceBetweenAndIsActiveTrueOrderByYearlyPriceAsc(BigDecimal minPrice, BigDecimal maxPrice);
/**
* Trouve les plans gratuits
*/
@Query("SELECT p FROM PlanEntity p WHERE (p.monthlyPrice = 0 OR p.monthlyPrice IS NULL) AND (p.yearlyPrice = 0 OR p.yearlyPrice IS NULL) AND p.isActive = true")
List<PlanEntity> findFreePlans();
/**
* Trouve les plans payants ordonnés par prix croissant
*/
@Query("SELECT p FROM PlanEntity p WHERE p.monthlyPrice > 0 AND p.isActive = true ORDER BY p.monthlyPrice ASC")
List<PlanEntity> findPaidPlansOrderByPrice();
// ===== RECHERCHES PAR FONCTIONNALITÉS =====
/**
* Trouve les plans incluant une fonctionnalité spécifique
*/
@Query("SELECT p FROM PlanEntity p WHERE JSON_CONTAINS(p.features, :feature, '$') = 1 AND p.isActive = true")
List<PlanEntity> findPlansWithFeature(@Param("feature") String feature);
/**
* Trouve les plans avec un nombre spécifique de fonctionnalités
*/
@Query("SELECT p FROM PlanEntity p WHERE JSON_LENGTH(p.features) = :featureCount AND p.isActive = true")
List<PlanEntity> findPlansByFeatureCount(@Param("featureCount") int featureCount);
/**
* Trouve les plans avec un nombre minimum de fonctionnalités
*/
@Query("SELECT p FROM PlanEntity p WHERE JSON_LENGTH(p.features) >= :minFeatures AND p.isActive = true ORDER BY JSON_LENGTH(p.features) ASC")
List<PlanEntity> findPlansWithMinimumFeatures(@Param("minFeatures") int minFeatures);
// ===== RECHERCHES PAR UTILISATEURS =====
/**
* Trouve les plans supportant un nombre d'utilisateurs spécifique
*/
List<PlanEntity> findByMaxUsersGreaterThanEqualAndIsActiveTrueOrderByMaxUsersAsc(Integer minUsers);
/**
* Trouve les plans avec utilisateurs illimités
*/
@Query("SELECT p FROM PlanEntity p WHERE p.maxUsers = -1 AND p.isActive = true")
List<PlanEntity> findUnlimitedUserPlans();
/**
* Trouve le plan le plus économique pour un nombre d'utilisateurs donné
*/
@Query("SELECT p FROM PlanEntity p WHERE (p.maxUsers >= :userCount OR p.maxUsers = -1) AND p.isActive = true ORDER BY p.monthlyPrice ASC LIMIT 1")
Optional<PlanEntity> findCheapestPlanForUsers(@Param("userCount") int userCount);
// ===== RECHERCHES PAR PÉRIODE D'ESSAI =====
/**
* Trouve les plans avec période d'essai
*/
@Query("SELECT p FROM PlanEntity p WHERE p.trialDurationDays > 0 AND p.isActive = true")
List<PlanEntity> findPlansWithTrial();
/**
* Trouve les plans par durée d'essai
*/
List<PlanEntity> findByTrialDurationDaysAndIsActiveTrueOrderByDisplayOrderAsc(Integer trialDays);
// ===== RECHERCHES POUR RECOMMANDATIONS =====
/**
* Trouve les plans recommandés (avec métadonnées spécifiques)
*/
@Query("SELECT p FROM PlanEntity p WHERE JSON_EXTRACT(p.metadata, '$.recommended') = true AND p.isActive = true ORDER BY p.displayOrder ASC")
List<PlanEntity> findRecommendedPlans();
/**
* Trouve les plans populaires
*/
@Query("SELECT p FROM PlanEntity p WHERE JSON_EXTRACT(p.metadata, '$.popular') = true AND p.isActive = true ORDER BY p.displayOrder ASC")
List<PlanEntity> findPopularPlans();
/**
* Trouve les plans pour entreprises
*/
@Query("SELECT p FROM PlanEntity p WHERE p.maxUsers = -1 OR p.maxUsers > 50 AND p.isActive = true ORDER BY p.monthlyPrice ASC")
List<PlanEntity> findEnterprisePlans();
// ===== COMPARAISON DE PLANS =====
/**
* Trouve les plans dans une gamme de prix pour comparaison
*/
@Query("""
SELECT p FROM PlanEntity p
WHERE p.isActive = true
AND p.monthlyPrice BETWEEN :basePrice * 0.5 AND :basePrice * 2
ORDER BY p.monthlyPrice ASC
""")
List<PlanEntity> findPlansForComparison(@Param("basePrice") BigDecimal basePrice);
/**
* Trouve le plan supérieur recommandé pour un upgrade
*/
@Query("""
SELECT p FROM PlanEntity p
WHERE p.monthlyPrice > :currentPrice
AND p.isActive = true
ORDER BY p.monthlyPrice ASC
LIMIT 1
""")
Optional<PlanEntity> findNextTierPlan(@Param("currentPrice") BigDecimal currentPrice);
/**
* Trouve le plan inférieur pour un downgrade
*/
@Query("""
SELECT p FROM PlanEntity p
WHERE p.monthlyPrice < :currentPrice
AND p.isActive = true
ORDER BY p.monthlyPrice DESC
LIMIT 1
""")
Optional<PlanEntity> findPreviousTierPlan(@Param("currentPrice") BigDecimal currentPrice);
// ===== STATISTIQUES =====
/**
* Compte les plans actifs
*/
long countByIsActiveTrue();
/**
* Trouve le prix mensuel moyen des plans actifs
*/
@Query("SELECT AVG(p.monthlyPrice) FROM PlanEntity p WHERE p.monthlyPrice > 0 AND p.isActive = true")
Double findAverageMonthlyPrice();
/**
* Trouve le prix mensuel médian des plans actifs
*/
@Query("""
SELECT p.monthlyPrice
FROM PlanEntity p
WHERE p.monthlyPrice > 0 AND p.isActive = true
ORDER BY p.monthlyPrice
LIMIT 1 OFFSET (SELECT COUNT(*) FROM PlanEntity WHERE monthlyPrice > 0 AND isActive = true) / 2
""")
BigDecimal findMedianMonthlyPrice();
// ===== RECHERCHES AVANCÉES =====
/**
* Recherche full-text dans les plans
*/
@Query("""
SELECT p FROM PlanEntity p
WHERE LOWER(p.name) LIKE LOWER(CONCAT('%', :searchTerm, '%'))
OR LOWER(p.type) LIKE LOWER(CONCAT('%', :searchTerm, '%'))
OR JSON_SEARCH(p.features, 'one', CONCAT('%', :searchTerm, '%')) IS NOT NULL
ORDER BY p.displayOrder ASC
""")
List<PlanEntity> searchPlans(@Param("searchTerm") String searchTerm);
/**
* Trouve les plans par multiple critères
*/
@Query("""
SELECT p FROM PlanEntity p
WHERE (:isActive IS NULL OR p.isActive = :isActive)
AND (:minPrice IS NULL OR p.monthlyPrice >= :minPrice)
AND (:maxPrice IS NULL OR p.monthlyPrice <= :maxPrice)
AND (:minUsers IS NULL OR p.maxUsers >= :minUsers OR p.maxUsers = -1)
ORDER BY p.displayOrder ASC, p.monthlyPrice ASC
""")
List<PlanEntity> findByMultipleCriteria(
@Param("isActive") Boolean isActive,
@Param("minPrice") BigDecimal minPrice,
@Param("maxPrice") BigDecimal maxPrice,
@Param("minUsers") Integer minUsers
);
/**
* Trouve les plans les plus utilisés (basé sur les abonnements)
*/
@Query("""
SELECT p, COUNT(s) as subscriptionCount
FROM PlanEntity p
LEFT JOIN SubscriptionEntity s ON (s.stripePriceId = p.stripePriceIdMonthly OR s.stripePriceId = p.stripePriceIdYearly)
WHERE p.isActive = true
GROUP BY p
ORDER BY subscriptionCount DESC
""")
List<Object[]> findMostUsedPlans(Pageable pageable);
}

View File

@ -0,0 +1,250 @@
package com.dh7789dev.xpeditis.repository;
import com.dh7789dev.xpeditis.entity.SubscriptionEntity;
import com.dh7789dev.xpeditis.entity.SubscriptionStatusEntity;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
/**
* Repository JPA pour les abonnements Stripe
*/
@Repository
public interface SubscriptionJpaRepository extends JpaRepository<SubscriptionEntity, UUID> {
// ===== RECHERCHES DE BASE =====
/**
* Trouve un abonnement par son ID Stripe
*/
Optional<SubscriptionEntity> findByStripeSubscriptionId(String stripeSubscriptionId);
/**
* Trouve un abonnement par son ID client Stripe
*/
List<SubscriptionEntity> findByStripeCustomerId(String stripeCustomerId);
/**
* Trouve un abonnement par son ID de prix Stripe
*/
List<SubscriptionEntity> findByStripePriceId(String stripePriceId);
/**
* Vérifie l'existence d'un abonnement par ID Stripe
*/
boolean existsByStripeSubscriptionId(String stripeSubscriptionId);
// ===== RECHERCHES PAR STATUT =====
/**
* Trouve les abonnements par statut
*/
List<SubscriptionEntity> findByStatusOrderByCreatedAtDesc(SubscriptionStatusEntity status);
/**
* Trouve les abonnements par statut avec pagination
*/
Page<SubscriptionEntity> findByStatus(SubscriptionStatusEntity status, Pageable pageable);
/**
* Trouve les abonnements actifs
*/
@Query("SELECT s FROM SubscriptionEntity s WHERE s.status = 'ACTIVE' ORDER BY s.createdAt DESC")
List<SubscriptionEntity> findActiveSubscriptions();
/**
* Trouve les abonnements en période d'essai
*/
@Query("SELECT s FROM SubscriptionEntity s WHERE s.status = 'TRIALING' ORDER BY s.trialEndDate ASC")
List<SubscriptionEntity> findTrialSubscriptions();
/**
* Trouve les abonnements nécessitant une attention (problèmes de paiement)
*/
@Query("SELECT s FROM SubscriptionEntity s WHERE s.status IN ('PAST_DUE', 'UNPAID', 'INCOMPLETE') ORDER BY s.nextBillingDate ASC")
List<SubscriptionEntity> findSubscriptionsRequiringAttention();
// ===== RECHERCHES PAR DATE =====
/**
* Trouve les abonnements dont la période d'essai se termine bientôt
*/
@Query("SELECT s FROM SubscriptionEntity s WHERE s.status = 'TRIALING' AND s.trialEndDate BETWEEN :now AND :endDate ORDER BY s.trialEndDate ASC")
List<SubscriptionEntity> findTrialsEndingBetween(@Param("now") LocalDateTime now, @Param("endDate") LocalDateTime endDate);
/**
* Trouve les abonnements avec facturation dans la période spécifiée
*/
@Query("SELECT s FROM SubscriptionEntity s WHERE s.nextBillingDate BETWEEN :startDate AND :endDate ORDER BY s.nextBillingDate ASC")
List<SubscriptionEntity> findSubscriptionsForBillingBetween(@Param("startDate") LocalDateTime startDate, @Param("endDate") LocalDateTime endDate);
/**
* Trouve les abonnements créés dans une période
*/
List<SubscriptionEntity> findByCreatedAtBetweenOrderByCreatedAtDesc(LocalDateTime startDate, LocalDateTime endDate);
/**
* Trouve les abonnements expirés (période courante terminée)
*/
@Query("SELECT s FROM SubscriptionEntity s WHERE s.currentPeriodEnd < :now AND s.status NOT IN ('CANCELED', 'UNPAID') ORDER BY s.currentPeriodEnd ASC")
List<SubscriptionEntity> findExpiredSubscriptions(@Param("now") LocalDateTime now);
// ===== RECHERCHES PAR CYCLE DE FACTURATION =====
/**
* Trouve les abonnements mensuels
*/
@Query("SELECT s FROM SubscriptionEntity s WHERE s.billingCycle = 'MONTHLY' ORDER BY s.createdAt DESC")
List<SubscriptionEntity> findMonthlySubscriptions();
/**
* Trouve les abonnements annuels
*/
@Query("SELECT s FROM SubscriptionEntity s WHERE s.billingCycle = 'YEARLY' ORDER BY s.createdAt DESC")
List<SubscriptionEntity> findYearlySubscriptions();
// ===== RECHERCHES POUR ANNULATION =====
/**
* Trouve les abonnements marqués pour annulation en fin de période
*/
@Query("SELECT s FROM SubscriptionEntity s WHERE s.cancelAtPeriodEnd = true ORDER BY s.currentPeriodEnd ASC")
List<SubscriptionEntity> findSubscriptionsToCancel();
/**
* Trouve les abonnements à annuler dans la période spécifiée
*/
@Query("SELECT s FROM SubscriptionEntity s WHERE s.cancelAtPeriodEnd = true AND s.currentPeriodEnd BETWEEN :startDate AND :endDate ORDER BY s.currentPeriodEnd ASC")
List<SubscriptionEntity> findSubscriptionsToCancelBetween(@Param("startDate") LocalDateTime startDate, @Param("endDate") LocalDateTime endDate);
// ===== STATISTIQUES ET MÉTRIQUES =====
/**
* Compte les abonnements par statut
*/
long countByStatus(SubscriptionStatusEntity status);
/**
* Compte les abonnements par cycle de facturation
*/
@Query("SELECT COUNT(s) FROM SubscriptionEntity s WHERE s.billingCycle = :billingCycle")
long countByBillingCycle(@Param("billingCycle") String billingCycle);
/**
* Compte les nouveaux abonnements dans une période
*/
long countByCreatedAtBetween(LocalDateTime startDate, LocalDateTime endDate);
/**
* Calcul du chiffre d'affaires mensuel récurrent (MRR)
*/
@Query("""
SELECT SUM(
CASE
WHEN s.billingCycle = 'MONTHLY' THEN
(SELECT p.monthlyPrice FROM PlanEntity p WHERE p.stripePriceIdMonthly = s.stripePriceId
OR p.stripePriceIdYearly = s.stripePriceId)
WHEN s.billingCycle = 'YEARLY' THEN
(SELECT p.yearlyPrice FROM PlanEntity p WHERE p.stripePriceIdMonthly = s.stripePriceId
OR p.stripePriceIdYearly = s.stripePriceId) / 12
ELSE 0
END
)
FROM SubscriptionEntity s
WHERE s.status = 'ACTIVE'
""")
Double calculateMonthlyRecurringRevenue();
/**
* Calcul du chiffre d'affaires annuel récurrent (ARR)
*/
@Query("""
SELECT SUM(
CASE
WHEN s.billingCycle = 'MONTHLY' THEN
(SELECT p.monthlyPrice FROM PlanEntity p WHERE p.stripePriceIdMonthly = s.stripePriceId
OR p.stripePriceIdYearly = s.stripePriceId) * 12
WHEN s.billingCycle = 'YEARLY' THEN
(SELECT p.yearlyPrice FROM PlanEntity p WHERE p.stripePriceIdMonthly = s.stripePriceId
OR p.stripePriceIdYearly = s.stripePriceId)
ELSE 0
END
)
FROM SubscriptionEntity s
WHERE s.status = 'ACTIVE'
""")
Double calculateAnnualRecurringRevenue();
// ===== OPÉRATIONS DE MAINTENANCE =====
/**
* Met à jour le statut des abonnements expirés
*/
@Modifying
@Query("UPDATE SubscriptionEntity s SET s.status = 'CANCELED' WHERE s.currentPeriodEnd < :now AND s.cancelAtPeriodEnd = true")
int cancelExpiredSubscriptions(@Param("now") LocalDateTime now);
/**
* Met à jour le statut des essais expirés
*/
@Modifying
@Query("UPDATE SubscriptionEntity s SET s.status = 'INCOMPLETE_EXPIRED' WHERE s.status = 'TRIALING' AND s.trialEndDate < :now")
int expireTrialSubscriptions(@Param("now") LocalDateTime now);
/**
* Supprime les anciens abonnements annulés
*/
@Modifying
@Query("DELETE FROM SubscriptionEntity s WHERE s.status = 'CANCELED' AND s.modifiedDate < :cutoffDate")
int deleteOldCanceledSubscriptions(@Param("cutoffDate") java.time.Instant cutoffDate);
// ===== RECHERCHES AVANCÉES =====
/**
* Trouve les abonnements par multiple critères
*/
@Query("""
SELECT s FROM SubscriptionEntity s
WHERE (:status IS NULL OR s.status = :status)
AND (:billingCycle IS NULL OR s.billingCycle = :billingCycle)
AND (:customerId IS NULL OR s.stripeCustomerId = :customerId)
ORDER BY s.createdAt DESC
""")
List<SubscriptionEntity> findByMultipleCriteria(
@Param("status") SubscriptionStatusEntity status,
@Param("billingCycle") String billingCycle,
@Param("customerId") String customerId
);
/**
* Recherche full-text dans les abonnements (ID client, subscription ID)
*/
@Query("""
SELECT s FROM SubscriptionEntity s
WHERE s.stripeSubscriptionId LIKE %:searchTerm%
OR s.stripeCustomerId LIKE %:searchTerm%
ORDER BY s.createdAt DESC
""")
List<SubscriptionEntity> searchSubscriptions(@Param("searchTerm") String searchTerm);
/**
* Trouve les top clients par nombre d'abonnements
*/
@Query("""
SELECT s.stripeCustomerId, COUNT(s) as subscriptionCount
FROM SubscriptionEntity s
WHERE s.status = 'ACTIVE'
GROUP BY s.stripeCustomerId
ORDER BY subscriptionCount DESC
""")
List<Object[]> findTopCustomersBySubscriptionCount(Pageable pageable);
}

View File

@ -0,0 +1,353 @@
-- Migration V5: Création du système d'abonnements et de paiements Stripe
-- Cette migration étend le système de licences existant avec les fonctionnalités d'abonnement
-- ===============================================
-- 1. MISE À JOUR DE LA TABLE LICENSES EXISTANTE
-- ===============================================
-- Ajouter les nouvelles colonnes à la table licenses existante
ALTER TABLE licenses
ADD COLUMN status VARCHAR(50) DEFAULT 'ACTIVE' AFTER type,
ADD COLUMN grace_period_end_date TIMESTAMP NULL AFTER expiration_date,
ADD COLUMN features_enabled JSON AFTER max_users,
ADD COLUMN subscription_id BINARY(16) NULL AFTER company_id,
ADD COLUMN updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP AFTER created_at,
ADD COLUMN last_checked_at TIMESTAMP NULL AFTER updated_at;
-- Mettre à jour les licences existantes avec le nouveau statut
UPDATE licenses SET status = 'ACTIVE' WHERE is_active = 1 AND (expiration_date IS NULL OR expiration_date > CURDATE());
UPDATE licenses SET status = 'EXPIRED' WHERE expiration_date IS NOT NULL AND expiration_date < CURDATE();
UPDATE licenses SET status = 'SUSPENDED' WHERE is_active = 0;
-- ===============================================
-- 2. TABLE DES PLANS D'ABONNEMENT
-- ===============================================
CREATE TABLE plans (
id BINARY(16) NOT NULL,
name VARCHAR(100) NOT NULL,
type VARCHAR(50) NOT NULL,
stripe_price_id_monthly VARCHAR(255),
stripe_price_id_yearly VARCHAR(255),
monthly_price DECIMAL(10,2),
yearly_price DECIMAL(10,2),
max_users INTEGER NOT NULL,
features JSON,
trial_duration_days INTEGER DEFAULT 14,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
display_order INTEGER,
metadata JSON,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
created_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
modified_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
created_by VARCHAR(255) DEFAULT 'SYSTEM',
modified_by VARCHAR(255),
PRIMARY KEY (id),
UNIQUE KEY uk_plan_name (name),
UNIQUE KEY uk_plan_type (type),
INDEX idx_plan_active (is_active),
INDEX idx_plan_type (type),
INDEX idx_plan_display_order (display_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ===============================================
-- 3. TABLE DES ABONNEMENTS STRIPE
-- ===============================================
CREATE TABLE subscriptions (
id BINARY(16) NOT NULL,
stripe_subscription_id VARCHAR(255) UNIQUE NOT NULL,
stripe_customer_id VARCHAR(255) NOT NULL,
stripe_price_id VARCHAR(255) NOT NULL,
status VARCHAR(50) NOT NULL,
current_period_start TIMESTAMP NOT NULL,
current_period_end TIMESTAMP NOT NULL,
cancel_at_period_end BOOLEAN DEFAULT FALSE,
billing_cycle VARCHAR(20) NOT NULL,
next_billing_date TIMESTAMP,
trial_end_date TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
created_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
modified_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
created_by VARCHAR(255) DEFAULT 'SYSTEM',
modified_by VARCHAR(255),
PRIMARY KEY (id),
UNIQUE KEY uk_stripe_subscription_id (stripe_subscription_id),
INDEX idx_subscription_status (status),
INDEX idx_subscription_customer (stripe_customer_id),
INDEX idx_subscription_next_billing (next_billing_date),
INDEX idx_subscription_trial_end (trial_end_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ===============================================
-- 4. TABLE DES FACTURES
-- ===============================================
CREATE TABLE invoices (
id BINARY(16) NOT NULL,
stripe_invoice_id VARCHAR(255) UNIQUE NOT NULL,
invoice_number VARCHAR(50) UNIQUE NOT NULL,
subscription_id BINARY(16) NOT NULL,
status VARCHAR(50) NOT NULL,
amount_due DECIMAL(10,2) NOT NULL,
amount_paid DECIMAL(10,2) DEFAULT 0,
currency VARCHAR(3) NOT NULL DEFAULT 'EUR',
billing_period_start TIMESTAMP NOT NULL,
billing_period_end TIMESTAMP NOT NULL,
due_date TIMESTAMP,
paid_at TIMESTAMP,
invoice_pdf_url TEXT,
hosted_invoice_url TEXT,
attempt_count INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
modified_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
created_by VARCHAR(255) DEFAULT 'SYSTEM',
modified_by VARCHAR(255),
PRIMARY KEY (id),
UNIQUE KEY uk_stripe_invoice_id (stripe_invoice_id),
UNIQUE KEY uk_invoice_number (invoice_number),
FOREIGN KEY (subscription_id) REFERENCES subscriptions(id) ON DELETE CASCADE,
INDEX idx_invoice_status (status),
INDEX idx_invoice_due_date (due_date),
INDEX idx_invoice_status_due (status, due_date),
INDEX idx_invoice_subscription (subscription_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ===============================================
-- 5. TABLE DES LIGNES DE FACTURE
-- ===============================================
CREATE TABLE invoice_line_items (
id BINARY(16) NOT NULL,
invoice_id BINARY(16) NOT NULL,
description TEXT,
quantity INTEGER DEFAULT 1,
unit_price DECIMAL(10,2),
amount DECIMAL(10,2) NOT NULL,
stripe_price_id VARCHAR(255),
period_start TIMESTAMP,
period_end TIMESTAMP,
prorated BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
modified_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
created_by VARCHAR(255) DEFAULT 'SYSTEM',
modified_by VARCHAR(255),
PRIMARY KEY (id),
FOREIGN KEY (invoice_id) REFERENCES invoices(id) ON DELETE CASCADE,
INDEX idx_line_item_invoice (invoice_id),
INDEX idx_line_item_period (period_start, period_end)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ===============================================
-- 6. TABLE DES MÉTHODES DE PAIEMENT
-- ===============================================
CREATE TABLE payment_methods (
id BINARY(16) NOT NULL,
stripe_payment_method_id VARCHAR(255) UNIQUE NOT NULL,
type VARCHAR(50) NOT NULL,
is_default BOOLEAN DEFAULT FALSE,
card_brand VARCHAR(50),
card_last4 VARCHAR(4),
card_exp_month INTEGER,
card_exp_year INTEGER,
bank_name VARCHAR(100),
company_id BINARY(16) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
modified_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
created_by VARCHAR(255) DEFAULT 'SYSTEM',
modified_by VARCHAR(255),
PRIMARY KEY (id),
UNIQUE KEY uk_stripe_payment_method_id (stripe_payment_method_id),
FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE,
INDEX idx_payment_method_company (company_id),
INDEX idx_payment_method_default (company_id, is_default),
INDEX idx_payment_method_type (type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ===============================================
-- 7. TABLE DES ÉVÉNEMENTS WEBHOOK
-- ===============================================
CREATE TABLE payment_events (
id BINARY(16) NOT NULL,
stripe_event_id VARCHAR(255) UNIQUE NOT NULL,
event_type VARCHAR(100) NOT NULL,
status VARCHAR(50) NOT NULL DEFAULT 'PENDING',
payload JSON NOT NULL,
processed_at TIMESTAMP NULL,
error_message TEXT,
retry_count INTEGER DEFAULT 0,
subscription_id BINARY(16),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
modified_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
created_by VARCHAR(255) DEFAULT 'SYSTEM',
modified_by VARCHAR(255),
PRIMARY KEY (id),
UNIQUE KEY uk_stripe_event_id (stripe_event_id),
FOREIGN KEY (subscription_id) REFERENCES subscriptions(id) ON DELETE SET NULL,
INDEX idx_event_status (status),
INDEX idx_event_type (event_type),
INDEX idx_event_retry (status, retry_count),
INDEX idx_event_subscription (subscription_id),
INDEX idx_event_created (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ===============================================
-- 8. AJOUT DES CLÉS ÉTRANGÈRES POUR LICENSES
-- ===============================================
-- Ajouter la contrainte de clé étrangère pour subscription_id dans licenses
ALTER TABLE licenses
ADD CONSTRAINT fk_license_subscription
FOREIGN KEY (subscription_id) REFERENCES subscriptions(id) ON DELETE SET NULL;
-- ===============================================
-- 9. INSERTION DES PLANS PAR DÉFAUT
-- ===============================================
INSERT INTO plans (
id, name, type, monthly_price, yearly_price, max_users,
features, trial_duration_days, is_active, display_order
) VALUES
(
UNHEX(REPLACE(UUID(), '-', '')),
'Trial',
'TRIAL',
0.00,
0.00,
5,
JSON_ARRAY('BASIC_QUOTES', 'DOCUMENT_TEMPLATES'),
14,
TRUE,
1
),
(
UNHEX(REPLACE(UUID(), '-', '')),
'Basic Plan',
'BASIC',
29.00,
278.00,
10,
JSON_ARRAY('BASIC_QUOTES', 'DOCUMENT_TEMPLATES', 'MULTI_CURRENCY', 'TEAM_COLLABORATION'),
14,
TRUE,
2
),
(
UNHEX(REPLACE(UUID(), '-', '')),
'Premium Plan',
'PREMIUM',
79.00,
758.00,
50,
JSON_ARRAY(
'BASIC_QUOTES', 'DOCUMENT_TEMPLATES', 'MULTI_CURRENCY', 'TEAM_COLLABORATION',
'ADVANCED_ANALYTICS', 'BULK_IMPORT', 'API_ACCESS', 'PRIORITY_SUPPORT', 'AUTOMATED_WORKFLOWS'
),
14,
TRUE,
3
),
(
UNHEX(REPLACE(UUID(), '-', '')),
'Enterprise Plan',
'ENTERPRISE',
199.00,
1908.00,
-1,
JSON_ARRAY(
'BASIC_QUOTES', 'DOCUMENT_TEMPLATES', 'MULTI_CURRENCY', 'TEAM_COLLABORATION',
'ADVANCED_ANALYTICS', 'BULK_IMPORT', 'API_ACCESS', 'PRIORITY_SUPPORT', 'AUTOMATED_WORKFLOWS',
'CUSTOM_BRANDING', 'CUSTOM_INTEGRATIONS', 'AUDIT_LOGS', 'SLA_GUARANTEE', 'DEDICATED_SUPPORT'
),
14,
TRUE,
4
);
-- ===============================================
-- 10. MISE À JOUR DES LICENCES EXISTANTES
-- ===============================================
-- Associer les licences existantes aux features selon leur type
UPDATE licenses l
INNER JOIN plans p ON l.type = p.type
SET l.features_enabled = p.features,
l.max_users = CASE
WHEN p.max_users = -1 THEN l.max_users
ELSE p.max_users
END
WHERE l.features_enabled IS NULL;
-- ===============================================
-- 11. AJOUT D'INDEX POUR PERFORMANCE
-- ===============================================
-- Index sur la table licenses pour les nouvelles colonnes
CREATE INDEX idx_license_status ON licenses(status);
CREATE INDEX idx_license_grace_period ON licenses(grace_period_end_date);
CREATE INDEX idx_license_subscription ON licenses(subscription_id);
CREATE INDEX idx_license_last_checked ON licenses(last_checked_at);
-- Index composites pour les requêtes fréquentes
CREATE INDEX idx_license_company_status ON licenses(company_id, status);
CREATE INDEX idx_subscription_status_next_billing ON subscriptions(status, next_billing_date);
CREATE INDEX idx_invoice_subscription_status ON invoices(subscription_id, status);
-- ===============================================
-- 12. VUES UTILITAIRES (OPTIONNEL)
-- ===============================================
-- Vue pour les licences actives avec informations d'abonnement
CREATE VIEW v_active_licenses AS
SELECT
l.id,
l.license_key,
l.type,
l.status,
l.max_users,
l.features_enabled,
l.expiration_date,
l.grace_period_end_date,
c.name as company_name,
s.stripe_subscription_id,
s.billing_cycle,
s.next_billing_date
FROM licenses l
LEFT JOIN companies c ON l.company_id = c.id
LEFT JOIN subscriptions s ON l.subscription_id = s.id
WHERE l.status IN ('ACTIVE', 'GRACE_PERIOD');
-- Vue pour les abonnements nécessitant une attention
CREATE VIEW v_subscriptions_attention AS
SELECT
s.id,
s.stripe_subscription_id,
s.status,
s.next_billing_date,
l.license_key,
c.name as company_name
FROM subscriptions s
INNER JOIN licenses l ON s.id = l.subscription_id
INNER JOIN companies c ON l.company_id = c.id
WHERE s.status IN ('PAST_DUE', 'UNPAID', 'INCOMPLETE')
OR (s.status = 'ACTIVE' AND s.next_billing_date <= DATE_ADD(NOW(), INTERVAL 3 DAY));
-- ===============================================
-- 13. COMMENTAIRES POUR DOCUMENTATION
-- ===============================================
-- Ajouter des commentaires aux tables principales
ALTER TABLE plans COMMENT = 'Plans d\'abonnement avec tarification et fonctionnalités';
ALTER TABLE subscriptions COMMENT = 'Abonnements Stripe avec cycle de facturation';
ALTER TABLE invoices COMMENT = 'Factures générées automatiquement par Stripe';
ALTER TABLE payment_methods COMMENT = 'Méthodes de paiement attachées aux entreprises';
ALTER TABLE payment_events COMMENT = 'Événements webhook reçus de Stripe pour traitement';
ALTER TABLE invoice_line_items COMMENT = 'Lignes détaillées des factures avec periods et prorata';

View File

@ -48,6 +48,7 @@
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding> <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<org.projectlombok.version>1.18.36</org.projectlombok.version> <org.projectlombok.version>1.18.36</org.projectlombok.version>
<org.mapstruct.version>1.6.3</org.mapstruct.version> <org.mapstruct.version>1.6.3</org.mapstruct.version>
<stripe.version>24.16.0</stripe.version>
</properties> </properties>
<dependencyManagement> <dependencyManagement>
@ -78,6 +79,11 @@
<artifactId>maven-compiler-plugin</artifactId> <artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version> <version>3.13.0</version>
</dependency> </dependency>
<dependency>
<groupId>com.stripe</groupId>
<artifactId>stripe-java</artifactId>
<version>${stripe.version}</version>
</dependency>
</dependencies> </dependencies>
</dependencyManagement> </dependencyManagement>