From da8da492d2ad027aafdf1834e6d7d8b8b5b232a5 Mon Sep 17 00:00:00 2001 From: David Date: Tue, 16 Sep 2025 16:41:51 +0200 Subject: [PATCH] feature test license and stripe abonnement --- .../controller/SubscriptionController.java | 317 +++++++++++++++ .../dh7789dev/xpeditis/dto/InvoiceDto.java | 247 ++++++++++++ .../xpeditis/dto/InvoiceLineItemDto.java | 187 +++++++++ .../xpeditis/dto/PaymentMethodDto.java | 256 ++++++++++++ .../com/dh7789dev/xpeditis/dto/PlanDto.java | 194 +++++++++ .../xpeditis/dto/SubscriptionDto.java | 168 ++++++++ .../request/CreateSubscriptionRequest.java | 59 +++ .../request/UpdateSubscriptionRequest.java | 44 +++ .../com/dh7789dev/xpeditis/PlanService.java | 91 +++++ .../xpeditis/SubscriptionService.java | 71 ++++ .../port/in/PlanManagementUseCase.java | 33 ++ .../in/SubscriptionManagementUseCase.java | 38 ++ .../xpeditis/dto/app/BillingCycle.java | 52 +++ .../xpeditis/dto/app/EventStatus.java | 31 ++ .../dh7789dev/xpeditis/dto/app/Invoice.java | 133 +++++++ .../xpeditis/dto/app/InvoiceLineItem.java | 97 +++++ .../xpeditis/dto/app/InvoiceStatus.java | 36 ++ .../dh7789dev/xpeditis/dto/app/License.java | 157 +++++++- .../xpeditis/dto/app/LicenseStatus.java | 36 ++ .../xpeditis/dto/app/LicenseType.java | 58 ++- .../xpeditis/dto/app/PaymentEvent.java | 161 ++++++++ .../xpeditis/dto/app/PaymentMethod.java | 145 +++++++ .../xpeditis/dto/app/PaymentType.java | 36 ++ .../com/dh7789dev/xpeditis/dto/app/Plan.java | 94 +++++ .../xpeditis/dto/app/PlanFeature.java | 119 ++++++ .../xpeditis/dto/app/Subscription.java | 145 +++++++ .../xpeditis/dto/app/SubscriptionStatus.java | 41 ++ .../xpeditis/dto/valueobject/Money.java | 168 ++++++++ .../xpeditis/LicenseServiceImpl.java | 167 +++++++- .../dh7789dev/xpeditis/PlanServiceImpl.java | 171 ++++++++ .../xpeditis/SubscriptionServiceImpl.java | 263 +++++++++++++ infrastructure/pom.xml | 12 + .../xpeditis/entity/BillingCycleEntity.java | 9 + .../xpeditis/entity/InvoiceEntity.java | 245 ++++++++++++ .../entity/InvoiceLineItemEntity.java | 217 ++++++++++ .../xpeditis/entity/PaymentEventEntity.java | 306 +++++++++++++++ .../xpeditis/entity/PaymentMethodEntity.java | 260 ++++++++++++ .../dh7789dev/xpeditis/entity/PlanEntity.java | 224 +++++++++++ .../xpeditis/entity/SubscriptionEntity.java | 167 ++++++++ .../entity/SubscriptionStatusEntity.java | 15 + .../mapper/InvoiceLineItemMapper.java | 134 +++++++ .../xpeditis/mapper/InvoiceMapper.java | 239 +++++++++++ .../xpeditis/mapper/PaymentEventMapper.java | 156 ++++++++ .../xpeditis/mapper/PaymentMethodMapper.java | 195 +++++++++ .../dh7789dev/xpeditis/mapper/PlanMapper.java | 211 ++++++++++ .../xpeditis/mapper/SubscriptionMapper.java | 201 ++++++++++ .../repository/InvoiceJpaRepository.java | 332 ++++++++++++++++ .../repository/PaymentEventJpaRepository.java | 333 ++++++++++++++++ .../PaymentMethodJpaRepository.java | 371 ++++++++++++++++++ .../repository/PlanJpaRepository.java | 285 ++++++++++++++ .../repository/SubscriptionJpaRepository.java | 250 ++++++++++++ .../V5__CREATE_SUBSCRIPTION_SYSTEM.sql | 353 +++++++++++++++++ pom.xml | 6 + 53 files changed, 8312 insertions(+), 24 deletions(-) create mode 100644 application/src/main/java/com/dh7789dev/xpeditis/controller/SubscriptionController.java create mode 100644 application/src/main/java/com/dh7789dev/xpeditis/dto/InvoiceDto.java create mode 100644 application/src/main/java/com/dh7789dev/xpeditis/dto/InvoiceLineItemDto.java create mode 100644 application/src/main/java/com/dh7789dev/xpeditis/dto/PaymentMethodDto.java create mode 100644 application/src/main/java/com/dh7789dev/xpeditis/dto/PlanDto.java create mode 100644 application/src/main/java/com/dh7789dev/xpeditis/dto/SubscriptionDto.java create mode 100644 application/src/main/java/com/dh7789dev/xpeditis/dto/request/CreateSubscriptionRequest.java create mode 100644 application/src/main/java/com/dh7789dev/xpeditis/dto/request/UpdateSubscriptionRequest.java create mode 100644 domain/api/src/main/java/com/dh7789dev/xpeditis/PlanService.java create mode 100644 domain/api/src/main/java/com/dh7789dev/xpeditis/SubscriptionService.java create mode 100644 domain/api/src/main/java/com/dh7789dev/xpeditis/port/in/PlanManagementUseCase.java create mode 100644 domain/api/src/main/java/com/dh7789dev/xpeditis/port/in/SubscriptionManagementUseCase.java create mode 100644 domain/data/src/main/java/com/dh7789dev/xpeditis/dto/app/BillingCycle.java create mode 100644 domain/data/src/main/java/com/dh7789dev/xpeditis/dto/app/EventStatus.java create mode 100644 domain/data/src/main/java/com/dh7789dev/xpeditis/dto/app/Invoice.java create mode 100644 domain/data/src/main/java/com/dh7789dev/xpeditis/dto/app/InvoiceLineItem.java create mode 100644 domain/data/src/main/java/com/dh7789dev/xpeditis/dto/app/InvoiceStatus.java create mode 100644 domain/data/src/main/java/com/dh7789dev/xpeditis/dto/app/LicenseStatus.java create mode 100644 domain/data/src/main/java/com/dh7789dev/xpeditis/dto/app/PaymentEvent.java create mode 100644 domain/data/src/main/java/com/dh7789dev/xpeditis/dto/app/PaymentMethod.java create mode 100644 domain/data/src/main/java/com/dh7789dev/xpeditis/dto/app/PaymentType.java create mode 100644 domain/data/src/main/java/com/dh7789dev/xpeditis/dto/app/Plan.java create mode 100644 domain/data/src/main/java/com/dh7789dev/xpeditis/dto/app/PlanFeature.java create mode 100644 domain/data/src/main/java/com/dh7789dev/xpeditis/dto/app/Subscription.java create mode 100644 domain/data/src/main/java/com/dh7789dev/xpeditis/dto/app/SubscriptionStatus.java create mode 100644 domain/data/src/main/java/com/dh7789dev/xpeditis/dto/valueobject/Money.java create mode 100644 domain/service/src/main/java/com/dh7789dev/xpeditis/PlanServiceImpl.java create mode 100644 domain/service/src/main/java/com/dh7789dev/xpeditis/SubscriptionServiceImpl.java create mode 100644 infrastructure/src/main/java/com/dh7789dev/xpeditis/entity/BillingCycleEntity.java create mode 100644 infrastructure/src/main/java/com/dh7789dev/xpeditis/entity/InvoiceEntity.java create mode 100644 infrastructure/src/main/java/com/dh7789dev/xpeditis/entity/InvoiceLineItemEntity.java create mode 100644 infrastructure/src/main/java/com/dh7789dev/xpeditis/entity/PaymentEventEntity.java create mode 100644 infrastructure/src/main/java/com/dh7789dev/xpeditis/entity/PaymentMethodEntity.java create mode 100644 infrastructure/src/main/java/com/dh7789dev/xpeditis/entity/PlanEntity.java create mode 100644 infrastructure/src/main/java/com/dh7789dev/xpeditis/entity/SubscriptionEntity.java create mode 100644 infrastructure/src/main/java/com/dh7789dev/xpeditis/entity/SubscriptionStatusEntity.java create mode 100644 infrastructure/src/main/java/com/dh7789dev/xpeditis/mapper/InvoiceLineItemMapper.java create mode 100644 infrastructure/src/main/java/com/dh7789dev/xpeditis/mapper/InvoiceMapper.java create mode 100644 infrastructure/src/main/java/com/dh7789dev/xpeditis/mapper/PaymentEventMapper.java create mode 100644 infrastructure/src/main/java/com/dh7789dev/xpeditis/mapper/PaymentMethodMapper.java create mode 100644 infrastructure/src/main/java/com/dh7789dev/xpeditis/mapper/PlanMapper.java create mode 100644 infrastructure/src/main/java/com/dh7789dev/xpeditis/mapper/SubscriptionMapper.java create mode 100644 infrastructure/src/main/java/com/dh7789dev/xpeditis/repository/InvoiceJpaRepository.java create mode 100644 infrastructure/src/main/java/com/dh7789dev/xpeditis/repository/PaymentEventJpaRepository.java create mode 100644 infrastructure/src/main/java/com/dh7789dev/xpeditis/repository/PaymentMethodJpaRepository.java create mode 100644 infrastructure/src/main/java/com/dh7789dev/xpeditis/repository/PlanJpaRepository.java create mode 100644 infrastructure/src/main/java/com/dh7789dev/xpeditis/repository/SubscriptionJpaRepository.java create mode 100644 infrastructure/src/main/resources/db/migration/structure/V5__CREATE_SUBSCRIPTION_SYSTEM.sql diff --git a/application/src/main/java/com/dh7789dev/xpeditis/controller/SubscriptionController.java b/application/src/main/java/com/dh7789dev/xpeditis/controller/SubscriptionController.java new file mode 100644 index 0000000..33080cb --- /dev/null +++ b/application/src/main/java/com/dh7789dev/xpeditis/controller/SubscriptionController.java @@ -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 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> 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 subscriptions = subscriptionService.findSubscriptions( + status, billingCycle, customerId, pageable); + + List dtos = subscriptions.getContent().stream() + .map(dtoMapper::toSummaryDto) + .collect(Collectors.toList()); + + Page 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 getSubscription( + @Parameter(description = "ID de l'abonnement") @PathVariable UUID subscriptionId) { + + log.debug("Récupération de l'abonnement {}", subscriptionId); + + Optional 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 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 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 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 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> getCompanySubscriptions( + @Parameter(description = "ID de l'entreprise") @PathVariable UUID companyId) { + + log.debug("Récupération des abonnements de l'entreprise {}", companyId); + + List subscriptions = subscriptionService.findByCompanyId(companyId); + List 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> getSubscriptionsRequiringAttention() { + + log.debug("Récupération des abonnements nécessitant une attention"); + + List subscriptions = subscriptionService.findSubscriptionsRequiringAttention(); + List 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> 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 subscriptions = subscriptionService.findTrialsEndingSoon(daysAhead); + List dtos = subscriptions.stream() + .map(dtoMapper::toSummaryDto) + .collect(Collectors.toList()); + + return ResponseEntity.ok(dtos); + } +} \ No newline at end of file diff --git a/application/src/main/java/com/dh7789dev/xpeditis/dto/InvoiceDto.java b/application/src/main/java/com/dh7789dev/xpeditis/dto/InvoiceDto.java new file mode 100644 index 0000000..1c25006 --- /dev/null +++ b/application/src/main/java/com/dh7789dev/xpeditis/dto/InvoiceDto.java @@ -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 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 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 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; + } +} \ No newline at end of file diff --git a/application/src/main/java/com/dh7789dev/xpeditis/dto/InvoiceLineItemDto.java b/application/src/main/java/com/dh7789dev/xpeditis/dto/InvoiceLineItemDto.java new file mode 100644 index 0000000..056d9c2 --- /dev/null +++ b/application/src/main/java/com/dh7789dev/xpeditis/dto/InvoiceLineItemDto.java @@ -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; + } +} \ No newline at end of file diff --git a/application/src/main/java/com/dh7789dev/xpeditis/dto/PaymentMethodDto.java b/application/src/main/java/com/dh7789dev/xpeditis/dto/PaymentMethodDto.java new file mode 100644 index 0000000..9966d9e --- /dev/null +++ b/application/src/main/java/com/dh7789dev/xpeditis/dto/PaymentMethodDto.java @@ -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; + } +} \ No newline at end of file diff --git a/application/src/main/java/com/dh7789dev/xpeditis/dto/PlanDto.java b/application/src/main/java/com/dh7789dev/xpeditis/dto/PlanDto.java new file mode 100644 index 0000000..6d1e5d6 --- /dev/null +++ b/application/src/main/java/com/dh7789dev/xpeditis/dto/PlanDto.java @@ -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 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 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 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 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" + } +} \ No newline at end of file diff --git a/application/src/main/java/com/dh7789dev/xpeditis/dto/SubscriptionDto.java b/application/src/main/java/com/dh7789dev/xpeditis/dto/SubscriptionDto.java new file mode 100644 index 0000000..22093b4 --- /dev/null +++ b/application/src/main/java/com/dh7789dev/xpeditis/dto/SubscriptionDto.java @@ -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 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; + } +} \ No newline at end of file diff --git a/application/src/main/java/com/dh7789dev/xpeditis/dto/request/CreateSubscriptionRequest.java b/application/src/main/java/com/dh7789dev/xpeditis/dto/request/CreateSubscriptionRequest.java new file mode 100644 index 0000000..21ea117 --- /dev/null +++ b/application/src/main/java/com/dh7789dev/xpeditis/dto/request/CreateSubscriptionRequest.java @@ -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 +} \ No newline at end of file diff --git a/application/src/main/java/com/dh7789dev/xpeditis/dto/request/UpdateSubscriptionRequest.java b/application/src/main/java/com/dh7789dev/xpeditis/dto/request/UpdateSubscriptionRequest.java new file mode 100644 index 0000000..0295325 --- /dev/null +++ b/application/src/main/java/com/dh7789dev/xpeditis/dto/request/UpdateSubscriptionRequest.java @@ -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; + } +} \ No newline at end of file diff --git a/domain/api/src/main/java/com/dh7789dev/xpeditis/PlanService.java b/domain/api/src/main/java/com/dh7789dev/xpeditis/PlanService.java new file mode 100644 index 0000000..db6ff54 --- /dev/null +++ b/domain/api/src/main/java/com/dh7789dev/xpeditis/PlanService.java @@ -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 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 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 requiredFeatures); + + /** + * Classe interne pour la comparaison de plans + */ + class PlanComparison { + private final Plan plan1; + private final Plan plan2; + private final List addedFeatures; + private final List removedFeatures; + private final Money priceDifference; + private final boolean isUpgrade; + + public PlanComparison(Plan plan1, Plan plan2, List addedFeatures, + List 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 getAddedFeatures() { return addedFeatures; } + public List getRemovedFeatures() { return removedFeatures; } + public Money getPriceDifference() { return priceDifference; } + public boolean isUpgrade() { return isUpgrade; } + } +} \ No newline at end of file diff --git a/domain/api/src/main/java/com/dh7789dev/xpeditis/SubscriptionService.java b/domain/api/src/main/java/com/dh7789dev/xpeditis/SubscriptionService.java new file mode 100644 index 0000000..cb7a1ee --- /dev/null +++ b/domain/api/src/main/java/com/dh7789dev/xpeditis/SubscriptionService.java @@ -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 getSubscriptionsRequiringAttention(); + + /** + * Démarre la période de grâce pour un abonnement + */ + void startGracePeriod(UUID subscriptionId); + + /** + * Suspend les abonnements impayés + */ + void suspendUnpaidSubscriptions(); +} \ No newline at end of file diff --git a/domain/api/src/main/java/com/dh7789dev/xpeditis/port/in/PlanManagementUseCase.java b/domain/api/src/main/java/com/dh7789dev/xpeditis/port/in/PlanManagementUseCase.java new file mode 100644 index 0000000..158e4b4 --- /dev/null +++ b/domain/api/src/main/java/com/dh7789dev/xpeditis/port/in/PlanManagementUseCase.java @@ -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 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 findSuitablePlansForUserCount(int userCount); +} \ No newline at end of file diff --git a/domain/api/src/main/java/com/dh7789dev/xpeditis/port/in/SubscriptionManagementUseCase.java b/domain/api/src/main/java/com/dh7789dev/xpeditis/port/in/SubscriptionManagementUseCase.java new file mode 100644 index 0000000..837f360 --- /dev/null +++ b/domain/api/src/main/java/com/dh7789dev/xpeditis/port/in/SubscriptionManagementUseCase.java @@ -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); +} \ No newline at end of file diff --git a/domain/data/src/main/java/com/dh7789dev/xpeditis/dto/app/BillingCycle.java b/domain/data/src/main/java/com/dh7789dev/xpeditis/dto/app/BillingCycle.java new file mode 100644 index 0000000..04cabf4 --- /dev/null +++ b/domain/data/src/main/java/com/dh7789dev/xpeditis/dto/app/BillingCycle.java @@ -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; + } +} \ No newline at end of file diff --git a/domain/data/src/main/java/com/dh7789dev/xpeditis/dto/app/EventStatus.java b/domain/data/src/main/java/com/dh7789dev/xpeditis/dto/app/EventStatus.java new file mode 100644 index 0000000..735f7ea --- /dev/null +++ b/domain/data/src/main/java/com/dh7789dev/xpeditis/dto/app/EventStatus.java @@ -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 +} \ No newline at end of file diff --git a/domain/data/src/main/java/com/dh7789dev/xpeditis/dto/app/Invoice.java b/domain/data/src/main/java/com/dh7789dev/xpeditis/dto/app/Invoice.java new file mode 100644 index 0000000..923382b --- /dev/null +++ b/domain/data/src/main/java/com/dh7789dev/xpeditis/dto/app/Invoice.java @@ -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 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); + } +} \ No newline at end of file diff --git a/domain/data/src/main/java/com/dh7789dev/xpeditis/dto/app/InvoiceLineItem.java b/domain/data/src/main/java/com/dh7789dev/xpeditis/dto/app/InvoiceLineItem.java new file mode 100644 index 0000000..b4e56ab --- /dev/null +++ b/domain/data/src/main/java/com/dh7789dev/xpeditis/dto/app/InvoiceLineItem.java @@ -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(); + } +} \ No newline at end of file diff --git a/domain/data/src/main/java/com/dh7789dev/xpeditis/dto/app/InvoiceStatus.java b/domain/data/src/main/java/com/dh7789dev/xpeditis/dto/app/InvoiceStatus.java new file mode 100644 index 0000000..b6d76da --- /dev/null +++ b/domain/data/src/main/java/com/dh7789dev/xpeditis/dto/app/InvoiceStatus.java @@ -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 +} \ No newline at end of file diff --git a/domain/data/src/main/java/com/dh7789dev/xpeditis/dto/app/License.java b/domain/data/src/main/java/com/dh7789dev/xpeditis/dto/app/License.java index c680a44..498fb7d 100644 --- a/domain/data/src/main/java/com/dh7789dev/xpeditis/dto/app/License.java +++ b/domain/data/src/main/java/com/dh7789dev/xpeditis/dto/app/License.java @@ -7,6 +7,7 @@ import lombok.NoArgsConstructor; import java.time.LocalDate; import java.time.LocalDateTime; +import java.util.Set; import java.util.UUID; @Data @@ -17,38 +18,184 @@ public class License { private UUID id; private String licenseKey; private LicenseType type; + private LicenseStatus status; private LocalDate startDate; private LocalDate expirationDate; private LocalDateTime issuedDate; private LocalDateTime expiryDate; + private LocalDateTime gracePeriodEndDate; private int maxUsers; + private Set featuresEnabled; private boolean isActive; private LocalDateTime createdAt; + private LocalDateTime updatedAt; + private LocalDateTime lastCheckedAt; private Company company; + private Subscription subscription; + /** + * @return true si la licence est expirée (date d'expiration dépassée) + */ public boolean isExpired() { return expirationDate != null && expirationDate.isBefore(LocalDate.now()); } - + public LocalDateTime getExpiryDate() { return expiryDate; } + /** + * @return true si la licence est active (pas suspendue, pas expirée) + */ + public boolean isActive() { + return status == LicenseStatus.ACTIVE; + } + + /** + * @return true si la licence est valide (active ou en période de grâce) + */ public boolean isValid() { - return isActive && !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) { - return !hasUserLimit() || currentUserCount < maxUsers; + return isValid() && (!hasUserLimit() || currentUserCount < maxUsers); } + /** + * @return true si ce type de licence a une limite d'utilisateurs + */ public boolean hasUserLimit() { return type != null && type.hasUserLimit(); } + /** + * @return le nombre de jours jusqu'à l'expiration + */ public long getDaysUntilExpiration() { - return expirationDate != null ? - java.time.temporal.ChronoUnit.DAYS.between(LocalDate.now(), expirationDate) : + return expirationDate != null ? + java.time.temporal.ChronoUnit.DAYS.between(LocalDate.now(), expirationDate) : Long.MAX_VALUE; } + + /** + * @return le nombre de jours restants en période de grâce (0 si pas en période de grâce) + */ + public long getDaysRemainingInGracePeriod() { + if (!isInGracePeriod() || gracePeriodEndDate == null) { + return 0; + } + return java.time.temporal.ChronoUnit.DAYS.between(LocalDateTime.now(), gracePeriodEndDate); + } + + /** + * @return true si la fonctionnalité est activée pour cette licence + */ + public boolean hasFeature(String featureCode) { + return featuresEnabled != null && featuresEnabled.contains(featureCode); + } + + /** + * Active une fonctionnalité + */ + public void enableFeature(String featureCode) { + if (featuresEnabled == null) { + featuresEnabled = new java.util.HashSet<>(); + } + featuresEnabled.add(featureCode); + this.updatedAt = LocalDateTime.now(); + } + + /** + * Désactive une fonctionnalité + */ + public void disableFeature(String featureCode) { + if (featuresEnabled != null) { + featuresEnabled.remove(featureCode); + this.updatedAt = LocalDateTime.now(); + } + } + + /** + * Met à jour le statut de la licence + */ + public void updateStatus(LicenseStatus newStatus) { + this.status = newStatus; + this.updatedAt = LocalDateTime.now(); + + // Si on sort de la période de grâce, on reset la date + if (newStatus != LicenseStatus.GRACE_PERIOD) { + this.gracePeriodEndDate = null; + } + } + + /** + * Démarre la période de grâce + */ + public void startGracePeriod(int gracePeriodDays) { + this.status = LicenseStatus.GRACE_PERIOD; + this.gracePeriodEndDate = LocalDateTime.now().plusDays(gracePeriodDays); + this.updatedAt = LocalDateTime.now(); + } + + /** + * Suspend la licence + */ + public void suspend() { + this.status = LicenseStatus.SUSPENDED; + this.isActive = false; + this.updatedAt = LocalDateTime.now(); + } + + /** + * Réactive la licence + */ + public void reactivate() { + this.status = LicenseStatus.ACTIVE; + this.isActive = true; + this.gracePeriodEndDate = null; + this.updatedAt = LocalDateTime.now(); + } + + /** + * @return true si la licence nécessite une attention immédiate + */ + public boolean requiresAttention() { + return isSuspended() + || (isInGracePeriod() && getDaysRemainingInGracePeriod() <= 1) + || (getDaysUntilExpiration() <= 7 && getDaysUntilExpiration() > 0); + } + + /** + * Met à jour la dernière vérification + */ + public void updateLastChecked() { + this.lastCheckedAt = LocalDateTime.now(); + } } diff --git a/domain/data/src/main/java/com/dh7789dev/xpeditis/dto/app/LicenseStatus.java b/domain/data/src/main/java/com/dh7789dev/xpeditis/dto/app/LicenseStatus.java new file mode 100644 index 0000000..997689c --- /dev/null +++ b/domain/data/src/main/java/com/dh7789dev/xpeditis/dto/app/LicenseStatus.java @@ -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 +} \ No newline at end of file diff --git a/domain/data/src/main/java/com/dh7789dev/xpeditis/dto/app/LicenseType.java b/domain/data/src/main/java/com/dh7789dev/xpeditis/dto/app/LicenseType.java index 31a08ec..395f019 100644 --- a/domain/data/src/main/java/com/dh7789dev/xpeditis/dto/app/LicenseType.java +++ b/domain/data/src/main/java/com/dh7789dev/xpeditis/dto/app/LicenseType.java @@ -1,17 +1,25 @@ package com.dh7789dev.xpeditis.dto.app; +import java.math.BigDecimal; + public enum LicenseType { - TRIAL(5, 30), - BASIC(10, -1), - PREMIUM(50, -1), - ENTERPRISE(-1, -1); + TRIAL(5, 30, "Trial", BigDecimal.ZERO, BigDecimal.ZERO), + BASIC(10, -1, "Basic Plan", BigDecimal.valueOf(29.00), BigDecimal.valueOf(278.00)), + PREMIUM(50, -1, "Premium Plan", BigDecimal.valueOf(79.00), BigDecimal.valueOf(758.00)), + ENTERPRISE(-1, -1, "Enterprise Plan", BigDecimal.valueOf(199.00), BigDecimal.valueOf(1908.00)); private final int maxUsers; private final int durationDays; + private final String description; + private final BigDecimal basePrice; + private final BigDecimal yearlyPrice; - LicenseType(int maxUsers, int durationDays) { + LicenseType(int maxUsers, int durationDays, String description, BigDecimal basePrice, BigDecimal yearlyPrice) { this.maxUsers = maxUsers; this.durationDays = durationDays; + this.description = description; + this.basePrice = basePrice; + this.yearlyPrice = yearlyPrice; } public int getMaxUsers() { @@ -29,4 +37,44 @@ public enum LicenseType { public boolean hasTimeLimit() { return durationDays > 0; } + + public String getDescription() { + return description; + } + + public BigDecimal getBasePrice() { + return basePrice; + } + + public BigDecimal getYearlyPrice() { + return yearlyPrice; + } + + /** + * @return true si c'est un plan gratuit + */ + public boolean isFree() { + return this == TRIAL; + } + + /** + * @return true si c'est un plan payant + */ + public boolean isPaid() { + return !isFree(); + } + + /** + * @return le pourcentage d'économies annuelles + */ + public BigDecimal getYearlySavingsPercentage() { + if (basePrice.compareTo(BigDecimal.ZERO) == 0 || yearlyPrice.compareTo(BigDecimal.ZERO) == 0) { + return BigDecimal.ZERO; + } + + BigDecimal yearlyEquivalent = basePrice.multiply(BigDecimal.valueOf(12)); + BigDecimal savings = yearlyEquivalent.subtract(yearlyPrice); + return savings.divide(yearlyEquivalent, 4, java.math.RoundingMode.HALF_UP) + .multiply(BigDecimal.valueOf(100)); + } } \ No newline at end of file diff --git a/domain/data/src/main/java/com/dh7789dev/xpeditis/dto/app/PaymentEvent.java b/domain/data/src/main/java/com/dh7789dev/xpeditis/dto/app/PaymentEvent.java new file mode 100644 index 0000000..eff6de5 --- /dev/null +++ b/domain/data/src/main/java/com/dh7789dev/xpeditis/dto/app/PaymentEvent.java @@ -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 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; + } + } +} \ No newline at end of file diff --git a/domain/data/src/main/java/com/dh7789dev/xpeditis/dto/app/PaymentMethod.java b/domain/data/src/main/java/com/dh7789dev/xpeditis/dto/app/PaymentMethod.java new file mode 100644 index 0000000..a237ac6 --- /dev/null +++ b/domain/data/src/main/java/com/dh7789dev/xpeditis/dto/app/PaymentMethod.java @@ -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; + } +} \ No newline at end of file diff --git a/domain/data/src/main/java/com/dh7789dev/xpeditis/dto/app/PaymentType.java b/domain/data/src/main/java/com/dh7789dev/xpeditis/dto/app/PaymentType.java new file mode 100644 index 0000000..a2fa495 --- /dev/null +++ b/domain/data/src/main/java/com/dh7789dev/xpeditis/dto/app/PaymentType.java @@ -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 +} \ No newline at end of file diff --git a/domain/data/src/main/java/com/dh7789dev/xpeditis/dto/app/Plan.java b/domain/data/src/main/java/com/dh7789dev/xpeditis/dto/app/Plan.java new file mode 100644 index 0000000..b249ed1 --- /dev/null +++ b/domain/data/src/main/java/com/dh7789dev/xpeditis/dto/app/Plan.java @@ -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 features; + private Integer trialDurationDays; + private boolean isActive; + private Integer displayOrder; + private Map 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)); + } +} \ No newline at end of file diff --git a/domain/data/src/main/java/com/dh7789dev/xpeditis/dto/app/PlanFeature.java b/domain/data/src/main/java/com/dh7789dev/xpeditis/dto/app/PlanFeature.java new file mode 100644 index 0000000..bbef44c --- /dev/null +++ b/domain/data/src/main/java/com/dh7789dev/xpeditis/dto/app/PlanFeature.java @@ -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(); + } +} \ No newline at end of file diff --git a/domain/data/src/main/java/com/dh7789dev/xpeditis/dto/app/Subscription.java b/domain/data/src/main/java/com/dh7789dev/xpeditis/dto/app/Subscription.java new file mode 100644 index 0000000..fa76711 --- /dev/null +++ b/domain/data/src/main/java/com/dh7789dev/xpeditis/dto/app/Subscription.java @@ -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 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(); + } +} \ No newline at end of file diff --git a/domain/data/src/main/java/com/dh7789dev/xpeditis/dto/app/SubscriptionStatus.java b/domain/data/src/main/java/com/dh7789dev/xpeditis/dto/app/SubscriptionStatus.java new file mode 100644 index 0000000..7406621 --- /dev/null +++ b/domain/data/src/main/java/com/dh7789dev/xpeditis/dto/app/SubscriptionStatus.java @@ -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 +} \ No newline at end of file diff --git a/domain/data/src/main/java/com/dh7789dev/xpeditis/dto/valueobject/Money.java b/domain/data/src/main/java/com/dh7789dev/xpeditis/dto/valueobject/Money.java new file mode 100644 index 0000000..8031036 --- /dev/null +++ b/domain/data/src/main/java/com/dh7789dev/xpeditis/dto/valueobject/Money.java @@ -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(); + } +} \ No newline at end of file diff --git a/domain/service/src/main/java/com/dh7789dev/xpeditis/LicenseServiceImpl.java b/domain/service/src/main/java/com/dh7789dev/xpeditis/LicenseServiceImpl.java index d7266ce..8ed90e4 100644 --- a/domain/service/src/main/java/com/dh7789dev/xpeditis/LicenseServiceImpl.java +++ b/domain/service/src/main/java/com/dh7789dev/xpeditis/LicenseServiceImpl.java @@ -3,8 +3,14 @@ package com.dh7789dev.xpeditis; import com.dh7789dev.xpeditis.dto.app.Company; import com.dh7789dev.xpeditis.dto.app.License; import com.dh7789dev.xpeditis.dto.app.LicenseType; +import com.dh7789dev.xpeditis.dto.app.LicenseStatus; +import com.dh7789dev.xpeditis.dto.app.PlanFeature; 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; @Service @@ -18,43 +24,176 @@ public class LicenseServiceImpl implements LicenseService { @Override public boolean validateLicense(UUID companyId) { - // TODO: Implement license validation logic - return true; // Temporary implementation + License license = getActiveLicense(companyId); + 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 public boolean canAddUser(UUID companyId) { - // TODO: Implement user addition validation logic - return true; // Temporary implementation + License license = getActiveLicense(companyId); + 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 + @Transactional public License createTrialLicense(Company company) { - // TODO: Implement trial license creation logic - throw new UnsupportedOperationException("Not implemented yet"); + // Vérifier si l'entreprise a déjà une licence active + 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 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 + @Transactional public License upgradeLicense(UUID companyId, LicenseType newType) { - // TODO: Implement license upgrade logic - throw new UnsupportedOperationException("Not implemented yet"); + License currentLicense = getActiveLicense(companyId); + 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 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 + @Transactional public void deactivateLicense(UUID licenseId) { - // TODO: Implement license deactivation logic - throw new UnsupportedOperationException("Not implemented yet"); + License license = licenseRepository.findById(licenseId) + .orElseThrow(() -> new IllegalArgumentException("Licence non trouvée")); + + license.suspend(); + licenseRepository.save(license); } @Override public License getActiveLicense(UUID companyId) { - // TODO: Implement active license retrieval logic - throw new UnsupportedOperationException("Not implemented yet"); + return licenseRepository.findActiveLicenseByCompanyId(companyId) + .orElse(null); } @Override public long getDaysUntilExpiration(UUID companyId) { - // TODO: Implement days until expiration calculation logic - return Long.MAX_VALUE; // Temporary implementation + License license = getActiveLicense(companyId); + 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 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(); + } } } diff --git a/domain/service/src/main/java/com/dh7789dev/xpeditis/PlanServiceImpl.java b/domain/service/src/main/java/com/dh7789dev/xpeditis/PlanServiceImpl.java new file mode 100644 index 0000000..6d2ba18 --- /dev/null +++ b/domain/service/src/main/java/com/dh7789dev/xpeditis/PlanServiceImpl.java @@ -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 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 findSuitablePlansForUserCount(int userCount) { + return planRepository.findSuitableForUserCount(userCount); + } + + @Override + public PlanComparison comparePlans(Plan plan1, Plan plan2) { + Set features1 = plan1.getFeatures() != null ? plan1.getFeatures() : Set.of(); + Set features2 = plan2.getFeatures() != null ? plan2.getFeatures() : Set.of(); + + // Fonctionnalités ajoutées (dans plan2 mais pas dans plan1) + List addedFeatures = features2.stream() + .filter(feature -> !features1.contains(feature)) + .collect(Collectors.toList()); + + // Fonctionnalités supprimées (dans plan1 mais pas dans plan2) + List 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 requiredFeatures) { + List 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 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; + } + } +} \ No newline at end of file diff --git a/domain/service/src/main/java/com/dh7789dev/xpeditis/SubscriptionServiceImpl.java b/domain/service/src/main/java/com/dh7789dev/xpeditis/SubscriptionServiceImpl.java new file mode 100644 index 0000000..dcdf502 --- /dev/null +++ b/domain/service/src/main/java/com/dh7789dev/xpeditis/SubscriptionServiceImpl.java @@ -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 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 getSubscriptionsRequiringAttention() { + List pastDue = subscriptionRepository.findByStatus(SubscriptionStatus.PAST_DUE); + List unpaid = subscriptionRepository.findByStatus(SubscriptionStatus.UNPAID); + List 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 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); + } + } + } +} \ No newline at end of file diff --git a/infrastructure/pom.xml b/infrastructure/pom.xml index f34b02b..1119504 100755 --- a/infrastructure/pom.xml +++ b/infrastructure/pom.xml @@ -57,6 +57,18 @@ test + + + com.stripe + stripe-java + + + + + org.springframework.boot + spring-boot-starter-thymeleaf + + io.jsonwebtoken diff --git a/infrastructure/src/main/java/com/dh7789dev/xpeditis/entity/BillingCycleEntity.java b/infrastructure/src/main/java/com/dh7789dev/xpeditis/entity/BillingCycleEntity.java new file mode 100644 index 0000000..87e8654 --- /dev/null +++ b/infrastructure/src/main/java/com/dh7789dev/xpeditis/entity/BillingCycleEntity.java @@ -0,0 +1,9 @@ +package com.dh7789dev.xpeditis.entity; + +/** + * Énumération des cycles de facturation + */ +public enum BillingCycleEntity { + MONTHLY, // Mensuel + YEARLY // Annuel +} \ No newline at end of file diff --git a/infrastructure/src/main/java/com/dh7789dev/xpeditis/entity/InvoiceEntity.java b/infrastructure/src/main/java/com/dh7789dev/xpeditis/entity/InvoiceEntity.java new file mode 100644 index 0000000..57608a5 --- /dev/null +++ b/infrastructure/src/main/java/com/dh7789dev/xpeditis/entity/InvoiceEntity.java @@ -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 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 +} \ No newline at end of file diff --git a/infrastructure/src/main/java/com/dh7789dev/xpeditis/entity/InvoiceLineItemEntity.java b/infrastructure/src/main/java/com/dh7789dev/xpeditis/entity/InvoiceLineItemEntity.java new file mode 100644 index 0000000..7c8f532 --- /dev/null +++ b/infrastructure/src/main/java/com/dh7789dev/xpeditis/entity/InvoiceLineItemEntity.java @@ -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; + } +} \ No newline at end of file diff --git a/infrastructure/src/main/java/com/dh7789dev/xpeditis/entity/PaymentEventEntity.java b/infrastructure/src/main/java/com/dh7789dev/xpeditis/entity/PaymentEventEntity.java new file mode 100644 index 0000000..9654abc --- /dev/null +++ b/infrastructure/src/main/java/com/dh7789dev/xpeditis/entity/PaymentEventEntity.java @@ -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; + } +} \ No newline at end of file diff --git a/infrastructure/src/main/java/com/dh7789dev/xpeditis/entity/PaymentMethodEntity.java b/infrastructure/src/main/java/com/dh7789dev/xpeditis/entity/PaymentMethodEntity.java new file mode 100644 index 0000000..d5fa591 --- /dev/null +++ b/infrastructure/src/main/java/com/dh7789dev/xpeditis/entity/PaymentMethodEntity.java @@ -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) +} \ No newline at end of file diff --git a/infrastructure/src/main/java/com/dh7789dev/xpeditis/entity/PlanEntity.java b/infrastructure/src/main/java/com/dh7789dev/xpeditis/entity/PlanEntity.java new file mode 100644 index 0000000..96cbd12 --- /dev/null +++ b/infrastructure/src/main/java/com/dh7789dev/xpeditis/entity/PlanEntity.java @@ -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 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 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, String> { + private final com.fasterxml.jackson.databind.ObjectMapper objectMapper = new com.fasterxml.jackson.databind.ObjectMapper(); + + @Override + public String convertToDatabaseColumn(Set 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 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, String> { + private final com.fasterxml.jackson.databind.ObjectMapper objectMapper = new com.fasterxml.jackson.databind.ObjectMapper(); + + @Override + public String convertToDatabaseColumn(java.util.Map 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 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); + } + } + } +} \ No newline at end of file diff --git a/infrastructure/src/main/java/com/dh7789dev/xpeditis/entity/SubscriptionEntity.java b/infrastructure/src/main/java/com/dh7789dev/xpeditis/entity/SubscriptionEntity.java new file mode 100644 index 0000000..a1d001a --- /dev/null +++ b/infrastructure/src/main/java/com/dh7789dev/xpeditis/entity/SubscriptionEntity.java @@ -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 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); + } +} \ No newline at end of file diff --git a/infrastructure/src/main/java/com/dh7789dev/xpeditis/entity/SubscriptionStatusEntity.java b/infrastructure/src/main/java/com/dh7789dev/xpeditis/entity/SubscriptionStatusEntity.java new file mode 100644 index 0000000..5f53abe --- /dev/null +++ b/infrastructure/src/main/java/com/dh7789dev/xpeditis/entity/SubscriptionStatusEntity.java @@ -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 +} \ No newline at end of file diff --git a/infrastructure/src/main/java/com/dh7789dev/xpeditis/mapper/InvoiceLineItemMapper.java b/infrastructure/src/main/java/com/dh7789dev/xpeditis/mapper/InvoiceLineItemMapper.java new file mode 100644 index 0000000..6ab9e0a --- /dev/null +++ b/infrastructure/src/main/java/com/dh7789dev/xpeditis/mapper/InvoiceLineItemMapper.java @@ -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 toDomainList(List 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 toEntityList(List 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; + } +} \ No newline at end of file diff --git a/infrastructure/src/main/java/com/dh7789dev/xpeditis/mapper/InvoiceMapper.java b/infrastructure/src/main/java/com/dh7789dev/xpeditis/mapper/InvoiceMapper.java new file mode 100644 index 0000000..36451cc --- /dev/null +++ b/infrastructure/src/main/java/com/dh7789dev/xpeditis/mapper/InvoiceMapper.java @@ -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 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 toDomainList(List 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 toEntityList(List 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; + }; + } +} \ No newline at end of file diff --git a/infrastructure/src/main/java/com/dh7789dev/xpeditis/mapper/PaymentEventMapper.java b/infrastructure/src/main/java/com/dh7789dev/xpeditis/mapper/PaymentEventMapper.java new file mode 100644 index 0000000..6300bd8 --- /dev/null +++ b/infrastructure/src/main/java/com/dh7789dev/xpeditis/mapper/PaymentEventMapper.java @@ -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 toDomainList(List 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 toEntityList(List 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 où 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()); + } +} \ No newline at end of file diff --git a/infrastructure/src/main/java/com/dh7789dev/xpeditis/mapper/PaymentMethodMapper.java b/infrastructure/src/main/java/com/dh7789dev/xpeditis/mapper/PaymentMethodMapper.java new file mode 100644 index 0000000..ebb0dcc --- /dev/null +++ b/infrastructure/src/main/java/com/dh7789dev/xpeditis/mapper/PaymentMethodMapper.java @@ -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 toDomainList(List 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 toEntityList(List 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; + }; + } +} \ No newline at end of file diff --git a/infrastructure/src/main/java/com/dh7789dev/xpeditis/mapper/PlanMapper.java b/infrastructure/src/main/java/com/dh7789dev/xpeditis/mapper/PlanMapper.java new file mode 100644 index 0000000..456cee6 --- /dev/null +++ b/infrastructure/src/main/java/com/dh7789dev/xpeditis/mapper/PlanMapper.java @@ -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 toDomainList(List 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 toEntityList(List 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 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 metadata) { + if (entity == null) { + return; + } + + entity.setMetadata(metadata); + entity.setUpdatedAt(java.time.LocalDateTime.now()); + } +} \ No newline at end of file diff --git a/infrastructure/src/main/java/com/dh7789dev/xpeditis/mapper/SubscriptionMapper.java b/infrastructure/src/main/java/com/dh7789dev/xpeditis/mapper/SubscriptionMapper.java new file mode 100644 index 0000000..31a8d30 --- /dev/null +++ b/infrastructure/src/main/java/com/dh7789dev/xpeditis/mapper/SubscriptionMapper.java @@ -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 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 toDomainList(List 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 toEntityList(List 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; + }; + } +} \ No newline at end of file diff --git a/infrastructure/src/main/java/com/dh7789dev/xpeditis/repository/InvoiceJpaRepository.java b/infrastructure/src/main/java/com/dh7789dev/xpeditis/repository/InvoiceJpaRepository.java new file mode 100644 index 0000000..5d0469a --- /dev/null +++ b/infrastructure/src/main/java/com/dh7789dev/xpeditis/repository/InvoiceJpaRepository.java @@ -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 { + + // ===== RECHERCHES DE BASE ===== + + /** + * Trouve une facture par son ID Stripe + */ + Optional findByStripeInvoiceId(String stripeInvoiceId); + + /** + * Trouve une facture par son numéro + */ + Optional findByInvoiceNumber(String invoiceNumber); + + /** + * Trouve les factures d'un abonnement + */ + List findBySubscriptionIdOrderByCreatedAtDesc(UUID subscriptionId); + + /** + * Trouve les factures d'un abonnement avec pagination + */ + Page 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 findByStatusOrderByCreatedAtDesc(InvoiceStatusEntity status); + + /** + * Trouve les factures par statut avec pagination + */ + Page 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 findOpenInvoices(); + + /** + * Trouve les factures payées + */ + @Query("SELECT i FROM InvoiceEntity i WHERE i.status = 'PAID' ORDER BY i.paidAt DESC") + List findPaidInvoices(); + + /** + * Trouve les factures échouées + */ + @Query("SELECT i FROM InvoiceEntity i WHERE i.status = 'PAYMENT_FAILED' ORDER BY i.createdAt DESC") + List 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 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 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 findInvoicesDueSoon(@Param("now") LocalDateTime now, @Param("endDate") LocalDateTime endDate); + + /** + * Trouve les factures créées dans une période + */ + List 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 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 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 findInvoicesOverlappingPeriod(@Param("startDate") LocalDateTime startDate, @Param("endDate") LocalDateTime endDate); + + // ===== RECHERCHES PAR MONTANT ===== + + /** + * Trouve les factures par montant minimum + */ + List findByAmountDueGreaterThanEqualOrderByAmountDueDesc(BigDecimal minAmount); + + /** + * Trouve les factures dans une fourchette de montants + */ + List 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 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 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 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 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 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 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 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 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 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 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 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 + ); +} \ No newline at end of file diff --git a/infrastructure/src/main/java/com/dh7789dev/xpeditis/repository/PaymentEventJpaRepository.java b/infrastructure/src/main/java/com/dh7789dev/xpeditis/repository/PaymentEventJpaRepository.java new file mode 100644 index 0000000..1a6975c --- /dev/null +++ b/infrastructure/src/main/java/com/dh7789dev/xpeditis/repository/PaymentEventJpaRepository.java @@ -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 { + + // ===== RECHERCHES DE BASE ===== + + /** + * Trouve un événement par son ID Stripe + */ + Optional findByStripeEventId(String stripeEventId); + + /** + * Trouve les événements par type + */ + List findByEventTypeOrderByCreatedAtDesc(String eventType); + + /** + * Trouve les événements par type avec limitation + */ + List 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 findByStatusOrderByCreatedAtAsc(EventStatus status); + + /** + * Trouve les événements par statut triés par date de création (desc) + */ + List 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 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 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 findStuckProcessingEvents(@Param("cutoffTime") LocalDateTime cutoffTime); + + // ===== RECHERCHES PAR ABONNEMENT ===== + + /** + * Trouve les événements liés à un abonnement + */ + List 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 findRecentEventsBySubscription(@Param("subscriptionId") UUID subscriptionId, @Param("since") LocalDateTime since); + + // ===== RECHERCHES PAR DATE ===== + + /** + * Trouve les événements créés dans une période + */ + List 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 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 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 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 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 findPaymentEvents(); + + /** + * Trouve les événements par multiple types + */ + List findByEventTypeIn(List 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 findByEventTypeInAndStatusIn(@Param("eventTypes") List eventTypes, @Param("statuses") List 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 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 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 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 findByStatusIn(List 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 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 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 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 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 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 getTopEventGeneratingSubscriptions(@Param("since") LocalDateTime since, Pageable pageable); +} \ No newline at end of file diff --git a/infrastructure/src/main/java/com/dh7789dev/xpeditis/repository/PaymentMethodJpaRepository.java b/infrastructure/src/main/java/com/dh7789dev/xpeditis/repository/PaymentMethodJpaRepository.java new file mode 100644 index 0000000..19709b3 --- /dev/null +++ b/infrastructure/src/main/java/com/dh7789dev/xpeditis/repository/PaymentMethodJpaRepository.java @@ -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 { + + // ===== RECHERCHES DE BASE ===== + + /** + * Trouve une méthode de paiement par son ID Stripe + */ + Optional findByStripePaymentMethodId(String stripePaymentMethodId); + + /** + * Trouve toutes les méthodes de paiement d'une entreprise + */ + List 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 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 findByTypeOrderByCreatedAtDesc(PaymentMethodTypeEntity type); + + /** + * Trouve les méthodes de paiement par type pour une entreprise + */ + List 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 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 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 findSepaDebitMethods(); + + // ===== RECHERCHES PAR DÉFAUT ===== + + /** + * Trouve la méthode de paiement par défaut d'une entreprise + */ + Optional 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 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 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 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 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 findByCardBrandIgnoreCaseOrderByCreatedAtDesc(String cardBrand); + + /** + * Trouve les cartes d'une entreprise par marque + */ + List findByCompanyIdAndCardBrandIgnoreCaseOrderByCreatedAtDesc(UUID companyId, String cardBrand); + + /** + * Trouve les cartes par les 4 derniers chiffres + */ + List findByCardLast4OrderByCreatedAtDesc(String cardLast4); + + /** + * Trouve une carte spécifique d'une entreprise par marque et 4 derniers chiffres + */ + Optional findByCompanyIdAndCardBrandIgnoreCaseAndCardLast4( + UUID companyId, String cardBrand, String cardLast4 + ); + + // ===== RECHERCHES PAR BANQUE ===== + + /** + * Trouve les méthodes de paiement par banque + */ + List findByBankNameIgnoreCaseOrderByCreatedAtDesc(String bankName); + + /** + * Trouve les méthodes d'une entreprise par banque + */ + List 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 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 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 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 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 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 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 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 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 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 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 findPaymentMethodsRequiringAttention( + @Param("currentYear") int currentYear, + @Param("currentMonth") int currentMonth + ); +} \ No newline at end of file diff --git a/infrastructure/src/main/java/com/dh7789dev/xpeditis/repository/PlanJpaRepository.java b/infrastructure/src/main/java/com/dh7789dev/xpeditis/repository/PlanJpaRepository.java new file mode 100644 index 0000000..156e101 --- /dev/null +++ b/infrastructure/src/main/java/com/dh7789dev/xpeditis/repository/PlanJpaRepository.java @@ -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 { + + // ===== RECHERCHES DE BASE ===== + + /** + * Trouve un plan par son nom + */ + Optional findByName(String name); + + /** + * Trouve un plan par son type + */ + Optional findByType(String type); + + /** + * Trouve un plan par son ID de prix Stripe (mensuel) + */ + Optional findByStripePriceIdMonthly(String stripePriceIdMonthly); + + /** + * Trouve un plan par son ID de prix Stripe (annuel) + */ + Optional 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 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 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 findAvailablePlans(); + + /** + * Trouve tous les plans inactifs + */ + List findByIsActiveFalseOrderByNameAsc(); + + /** + * Trouve les plans actifs avec pagination + */ + Page findByIsActiveTrue(Pageable pageable); + + // ===== RECHERCHES PAR PRIX ===== + + /** + * Trouve les plans dans une fourchette de prix mensuel + */ + List findByMonthlyPriceBetweenAndIsActiveTrueOrderByMonthlyPriceAsc(BigDecimal minPrice, BigDecimal maxPrice); + + /** + * Trouve les plans dans une fourchette de prix annuel + */ + List 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 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 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 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 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 findPlansWithMinimumFeatures(@Param("minFeatures") int minFeatures); + + // ===== RECHERCHES PAR UTILISATEURS ===== + + /** + * Trouve les plans supportant un nombre d'utilisateurs spécifique + */ + List 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 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 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 findPlansWithTrial(); + + /** + * Trouve les plans par durée d'essai + */ + List 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 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 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 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 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 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 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 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 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 findMostUsedPlans(Pageable pageable); +} \ No newline at end of file diff --git a/infrastructure/src/main/java/com/dh7789dev/xpeditis/repository/SubscriptionJpaRepository.java b/infrastructure/src/main/java/com/dh7789dev/xpeditis/repository/SubscriptionJpaRepository.java new file mode 100644 index 0000000..3621850 --- /dev/null +++ b/infrastructure/src/main/java/com/dh7789dev/xpeditis/repository/SubscriptionJpaRepository.java @@ -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 { + + // ===== RECHERCHES DE BASE ===== + + /** + * Trouve un abonnement par son ID Stripe + */ + Optional findByStripeSubscriptionId(String stripeSubscriptionId); + + /** + * Trouve un abonnement par son ID client Stripe + */ + List findByStripeCustomerId(String stripeCustomerId); + + /** + * Trouve un abonnement par son ID de prix Stripe + */ + List 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 findByStatusOrderByCreatedAtDesc(SubscriptionStatusEntity status); + + /** + * Trouve les abonnements par statut avec pagination + */ + Page 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 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 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 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 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 findSubscriptionsForBillingBetween(@Param("startDate") LocalDateTime startDate, @Param("endDate") LocalDateTime endDate); + + /** + * Trouve les abonnements créés dans une période + */ + List 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 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 findMonthlySubscriptions(); + + /** + * Trouve les abonnements annuels + */ + @Query("SELECT s FROM SubscriptionEntity s WHERE s.billingCycle = 'YEARLY' ORDER BY s.createdAt DESC") + List 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 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 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 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 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 findTopCustomersBySubscriptionCount(Pageable pageable); +} \ No newline at end of file diff --git a/infrastructure/src/main/resources/db/migration/structure/V5__CREATE_SUBSCRIPTION_SYSTEM.sql b/infrastructure/src/main/resources/db/migration/structure/V5__CREATE_SUBSCRIPTION_SYSTEM.sql new file mode 100644 index 0000000..f9b760d --- /dev/null +++ b/infrastructure/src/main/resources/db/migration/structure/V5__CREATE_SUBSCRIPTION_SYSTEM.sql @@ -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'; \ No newline at end of file diff --git a/pom.xml b/pom.xml index d3d49fa..36a1645 100755 --- a/pom.xml +++ b/pom.xml @@ -48,6 +48,7 @@ UTF-8 1.18.36 1.6.3 + 24.16.0 @@ -78,6 +79,11 @@ maven-compiler-plugin 3.13.0 + + com.stripe + stripe-java + ${stripe.version} +