feature test license and stripe abonnement
This commit is contained in:
parent
b3ed387197
commit
da8da492d2
@ -0,0 +1,317 @@
|
||||
package com.dh7789dev.xpeditis.controller;
|
||||
|
||||
import com.dh7789dev.xpeditis.dto.SubscriptionDto;
|
||||
import com.dh7789dev.xpeditis.dto.request.CreateSubscriptionRequest;
|
||||
import com.dh7789dev.xpeditis.dto.request.UpdateSubscriptionRequest;
|
||||
import com.dh7789dev.xpeditis.port.in.SubscriptionService;
|
||||
import com.dh7789dev.xpeditis.mapper.SubscriptionDtoMapper;
|
||||
import com.dh7789dev.xpeditis.dto.app.Subscription;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageImpl;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Contrôleur REST pour la gestion des abonnements
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/subscriptions")
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "Subscriptions", description = "API de gestion des abonnements Stripe")
|
||||
public class SubscriptionController {
|
||||
|
||||
private final SubscriptionService subscriptionService;
|
||||
private final SubscriptionDtoMapper dtoMapper;
|
||||
|
||||
// ===== CRÉATION D'ABONNEMENT =====
|
||||
|
||||
@PostMapping
|
||||
@PreAuthorize("hasRole('ADMIN') or hasRole('COMPANY_ADMIN')")
|
||||
@Operation(summary = "Créer un nouvel abonnement")
|
||||
@ApiResponses({
|
||||
@ApiResponse(responseCode = "201", description = "Abonnement créé avec succès"),
|
||||
@ApiResponse(responseCode = "400", description = "Données invalides"),
|
||||
@ApiResponse(responseCode = "403", description = "Accès refusé"),
|
||||
@ApiResponse(responseCode = "409", description = "Abonnement déjà existant")
|
||||
})
|
||||
public ResponseEntity<SubscriptionDto> createSubscription(
|
||||
@Valid @RequestBody CreateSubscriptionRequest request) {
|
||||
|
||||
log.info("Création d'abonnement pour l'entreprise {}, plan {}",
|
||||
request.getCompanyId(), request.getPlanType());
|
||||
|
||||
try {
|
||||
Subscription subscription = subscriptionService.createSubscription(
|
||||
request.getCompanyId(),
|
||||
request.getPlanType(),
|
||||
request.getBillingCycle(),
|
||||
request.getPaymentMethodId(),
|
||||
request.getStartTrial(),
|
||||
request.getCustomTrialDays()
|
||||
);
|
||||
|
||||
SubscriptionDto dto = dtoMapper.toDto(subscription);
|
||||
log.info("Abonnement créé avec succès: {}", subscription.getStripeSubscriptionId());
|
||||
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(dto);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Erreur lors de la création de l'abonnement: {}", e.getMessage(), e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== CONSULTATION DES ABONNEMENTS =====
|
||||
|
||||
@GetMapping
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@Operation(summary = "Lister tous les abonnements")
|
||||
@ApiResponses({
|
||||
@ApiResponse(responseCode = "200", description = "Liste des abonnements"),
|
||||
@ApiResponse(responseCode = "403", description = "Accès refusé")
|
||||
})
|
||||
public ResponseEntity<Page<SubscriptionDto.Summary>> getAllSubscriptions(
|
||||
@Parameter(description = "Statut de l'abonnement") @RequestParam(required = false) String status,
|
||||
@Parameter(description = "Cycle de facturation") @RequestParam(required = false) String billingCycle,
|
||||
@Parameter(description = "ID du client Stripe") @RequestParam(required = false) String customerId,
|
||||
Pageable pageable) {
|
||||
|
||||
log.debug("Récupération des abonnements - statut: {}, cycle: {}, client: {}",
|
||||
status, billingCycle, customerId);
|
||||
|
||||
Page<Subscription> subscriptions = subscriptionService.findSubscriptions(
|
||||
status, billingCycle, customerId, pageable);
|
||||
|
||||
List<SubscriptionDto.Summary> dtos = subscriptions.getContent().stream()
|
||||
.map(dtoMapper::toSummaryDto)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
Page<SubscriptionDto.Summary> result = new PageImpl<>(dtos, pageable, subscriptions.getTotalElements());
|
||||
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
|
||||
@GetMapping("/{subscriptionId}")
|
||||
@PreAuthorize("hasRole('ADMIN') or (hasRole('COMPANY_ADMIN') and @subscriptionSecurity.canAccessSubscription(authentication, #subscriptionId))")
|
||||
@Operation(summary = "Obtenir les détails d'un abonnement")
|
||||
@ApiResponses({
|
||||
@ApiResponse(responseCode = "200", description = "Détails de l'abonnement"),
|
||||
@ApiResponse(responseCode = "403", description = "Accès refusé"),
|
||||
@ApiResponse(responseCode = "404", description = "Abonnement non trouvé")
|
||||
})
|
||||
public ResponseEntity<SubscriptionDto.Detailed> getSubscription(
|
||||
@Parameter(description = "ID de l'abonnement") @PathVariable UUID subscriptionId) {
|
||||
|
||||
log.debug("Récupération de l'abonnement {}", subscriptionId);
|
||||
|
||||
Optional<Subscription> subscription = subscriptionService.findById(subscriptionId);
|
||||
|
||||
if (subscription.isEmpty()) {
|
||||
log.warn("Abonnement {} non trouvé", subscriptionId);
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
SubscriptionDto.Detailed dto = dtoMapper.toDetailedDto(subscription.get());
|
||||
return ResponseEntity.ok(dto);
|
||||
}
|
||||
|
||||
// ===== MODIFICATION D'ABONNEMENT =====
|
||||
|
||||
@PutMapping("/{subscriptionId}")
|
||||
@PreAuthorize("hasRole('ADMIN') or (hasRole('COMPANY_ADMIN') and @subscriptionSecurity.canAccessSubscription(authentication, #subscriptionId))")
|
||||
@Operation(summary = "Modifier un abonnement")
|
||||
@ApiResponses({
|
||||
@ApiResponse(responseCode = "200", description = "Abonnement modifié"),
|
||||
@ApiResponse(responseCode = "400", description = "Données invalides"),
|
||||
@ApiResponse(responseCode = "403", description = "Accès refusé"),
|
||||
@ApiResponse(responseCode = "404", description = "Abonnement non trouvé")
|
||||
})
|
||||
public ResponseEntity<SubscriptionDto> updateSubscription(
|
||||
@Parameter(description = "ID de l'abonnement") @PathVariable UUID subscriptionId,
|
||||
@Valid @RequestBody UpdateSubscriptionRequest request) {
|
||||
|
||||
log.info("Modification de l'abonnement {} - nouveau plan: {}",
|
||||
subscriptionId, request.getNewPlanType());
|
||||
|
||||
try {
|
||||
Optional<Subscription> updated = Optional.empty();
|
||||
|
||||
if (request.getNewPlanType() != null || request.getNewBillingCycle() != null) {
|
||||
updated = Optional.of(subscriptionService.changeSubscriptionPlan(
|
||||
subscriptionId,
|
||||
request.getNewPlanType(),
|
||||
request.getNewBillingCycle(),
|
||||
request.getEnableProration(),
|
||||
request.getImmediateChange()
|
||||
));
|
||||
}
|
||||
|
||||
if (request.getNewPaymentMethodId() != null) {
|
||||
subscriptionService.updatePaymentMethod(subscriptionId, request.getNewPaymentMethodId());
|
||||
if (updated.isEmpty()) {
|
||||
updated = subscriptionService.findById(subscriptionId);
|
||||
}
|
||||
}
|
||||
|
||||
if (request.getCancelAtPeriodEnd() != null) {
|
||||
if (request.getCancelAtPeriodEnd()) {
|
||||
updated = Optional.of(subscriptionService.scheduleForCancellation(
|
||||
subscriptionId, request.getCancellationReason()));
|
||||
} else {
|
||||
updated = Optional.of(subscriptionService.reactivateSubscription(subscriptionId));
|
||||
}
|
||||
}
|
||||
|
||||
if (updated.isEmpty()) {
|
||||
log.warn("Aucune modification apportée à l'abonnement {}", subscriptionId);
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
|
||||
SubscriptionDto dto = dtoMapper.toDto(updated.get());
|
||||
log.info("Abonnement {} modifié avec succès", subscriptionId);
|
||||
|
||||
return ResponseEntity.ok(dto);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Erreur lors de la modification de l'abonnement {}: {}", subscriptionId, e.getMessage(), e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== ACTIONS SPÉCIFIQUES =====
|
||||
|
||||
@PostMapping("/{subscriptionId}/cancel")
|
||||
@PreAuthorize("hasRole('ADMIN') or (hasRole('COMPANY_ADMIN') and @subscriptionSecurity.canAccessSubscription(authentication, #subscriptionId))")
|
||||
@Operation(summary = "Annuler un abonnement immédiatement")
|
||||
@ApiResponses({
|
||||
@ApiResponse(responseCode = "200", description = "Abonnement annulé"),
|
||||
@ApiResponse(responseCode = "403", description = "Accès refusé"),
|
||||
@ApiResponse(responseCode = "404", description = "Abonnement non trouvé")
|
||||
})
|
||||
public ResponseEntity<SubscriptionDto> cancelSubscriptionImmediately(
|
||||
@Parameter(description = "ID de l'abonnement") @PathVariable UUID subscriptionId,
|
||||
@Parameter(description = "Raison de l'annulation") @RequestParam(required = false) String reason) {
|
||||
|
||||
log.info("Annulation immédiate de l'abonnement {} - raison: {}", subscriptionId, reason);
|
||||
|
||||
try {
|
||||
Subscription subscription = subscriptionService.cancelSubscriptionImmediately(subscriptionId, reason);
|
||||
SubscriptionDto dto = dtoMapper.toDto(subscription);
|
||||
|
||||
log.info("Abonnement {} annulé avec succès", subscriptionId);
|
||||
return ResponseEntity.ok(dto);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Erreur lors de l'annulation de l'abonnement {}: {}", subscriptionId, e.getMessage(), e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/{subscriptionId}/reactivate")
|
||||
@PreAuthorize("hasRole('ADMIN') or (hasRole('COMPANY_ADMIN') and @subscriptionSecurity.canAccessSubscription(authentication, #subscriptionId))")
|
||||
@Operation(summary = "Réactiver un abonnement annulé")
|
||||
@ApiResponses({
|
||||
@ApiResponse(responseCode = "200", description = "Abonnement réactivé"),
|
||||
@ApiResponse(responseCode = "403", description = "Accès refusé"),
|
||||
@ApiResponse(responseCode = "404", description = "Abonnement non trouvé"),
|
||||
@ApiResponse(responseCode = "409", description = "Abonnement ne peut pas être réactivé")
|
||||
})
|
||||
public ResponseEntity<SubscriptionDto> reactivateSubscription(
|
||||
@Parameter(description = "ID de l'abonnement") @PathVariable UUID subscriptionId) {
|
||||
|
||||
log.info("Réactivation de l'abonnement {}", subscriptionId);
|
||||
|
||||
try {
|
||||
Subscription subscription = subscriptionService.reactivateSubscription(subscriptionId);
|
||||
SubscriptionDto dto = dtoMapper.toDto(subscription);
|
||||
|
||||
log.info("Abonnement {} réactivé avec succès", subscriptionId);
|
||||
return ResponseEntity.ok(dto);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Erreur lors de la réactivation de l'abonnement {}: {}", subscriptionId, e.getMessage(), e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== RECHERCHES SPÉCIALISÉES =====
|
||||
|
||||
@GetMapping("/company/{companyId}")
|
||||
@PreAuthorize("hasRole('ADMIN') or (hasRole('COMPANY_ADMIN') and @companySecurity.canAccessCompany(authentication, #companyId))")
|
||||
@Operation(summary = "Obtenir les abonnements d'une entreprise")
|
||||
@ApiResponses({
|
||||
@ApiResponse(responseCode = "200", description = "Abonnements de l'entreprise"),
|
||||
@ApiResponse(responseCode = "403", description = "Accès refusé")
|
||||
})
|
||||
public ResponseEntity<List<SubscriptionDto.Summary>> getCompanySubscriptions(
|
||||
@Parameter(description = "ID de l'entreprise") @PathVariable UUID companyId) {
|
||||
|
||||
log.debug("Récupération des abonnements de l'entreprise {}", companyId);
|
||||
|
||||
List<Subscription> subscriptions = subscriptionService.findByCompanyId(companyId);
|
||||
List<SubscriptionDto.Summary> dtos = subscriptions.stream()
|
||||
.map(dtoMapper::toSummaryDto)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return ResponseEntity.ok(dtos);
|
||||
}
|
||||
|
||||
@GetMapping("/requiring-attention")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@Operation(summary = "Obtenir les abonnements nécessitant une attention")
|
||||
@ApiResponses({
|
||||
@ApiResponse(responseCode = "200", description = "Abonnements nécessitant une attention"),
|
||||
@ApiResponse(responseCode = "403", description = "Accès refusé")
|
||||
})
|
||||
public ResponseEntity<List<SubscriptionDto.Summary>> getSubscriptionsRequiringAttention() {
|
||||
|
||||
log.debug("Récupération des abonnements nécessitant une attention");
|
||||
|
||||
List<Subscription> subscriptions = subscriptionService.findSubscriptionsRequiringAttention();
|
||||
List<SubscriptionDto.Summary> dtos = subscriptions.stream()
|
||||
.map(dtoMapper::toSummaryDto)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return ResponseEntity.ok(dtos);
|
||||
}
|
||||
|
||||
@GetMapping("/trial/ending-soon")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@Operation(summary = "Obtenir les abonnements en essai qui se terminent bientôt")
|
||||
@ApiResponses({
|
||||
@ApiResponse(responseCode = "200", description = "Essais se terminant bientôt"),
|
||||
@ApiResponse(responseCode = "403", description = "Accès refusé")
|
||||
})
|
||||
public ResponseEntity<List<SubscriptionDto.Summary>> getTrialsEndingSoon(
|
||||
@Parameter(description = "Nombre de jours d'avance") @RequestParam(defaultValue = "7") int daysAhead) {
|
||||
|
||||
log.debug("Récupération des essais se terminant dans {} jours", daysAhead);
|
||||
|
||||
List<Subscription> subscriptions = subscriptionService.findTrialsEndingSoon(daysAhead);
|
||||
List<SubscriptionDto.Summary> dtos = subscriptions.stream()
|
||||
.map(dtoMapper::toSummaryDto)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return ResponseEntity.ok(dtos);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,247 @@
|
||||
package com.dh7789dev.xpeditis.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import jakarta.validation.constraints.*;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* DTO pour les factures dans les réponses API
|
||||
*/
|
||||
@Data
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class InvoiceDto {
|
||||
|
||||
private UUID id;
|
||||
|
||||
@NotBlank(message = "L'ID Stripe de la facture est obligatoire")
|
||||
private String stripeInvoiceId;
|
||||
|
||||
@NotBlank(message = "Le numéro de facture est obligatoire")
|
||||
@Size(max = 50, message = "Le numéro de facture ne peut pas dépasser 50 caractères")
|
||||
private String invoiceNumber;
|
||||
|
||||
private UUID subscriptionId;
|
||||
|
||||
@NotNull(message = "Le statut est obligatoire")
|
||||
private String status;
|
||||
|
||||
@NotNull(message = "Le montant dû est obligatoire")
|
||||
@DecimalMin(value = "0.0", message = "Le montant dû doit être positif")
|
||||
@Digits(integer = 8, fraction = 2, message = "Le montant dû doit avoir au maximum 8 chiffres avant la virgule et 2 après")
|
||||
private BigDecimal amountDue;
|
||||
|
||||
@DecimalMin(value = "0.0", message = "Le montant payé doit être positif")
|
||||
@Digits(integer = 8, fraction = 2, message = "Le montant payé doit avoir au maximum 8 chiffres avant la virgule et 2 après")
|
||||
private BigDecimal amountPaid = BigDecimal.ZERO;
|
||||
|
||||
@NotBlank(message = "La devise est obligatoire")
|
||||
@Size(min = 3, max = 3, message = "La devise doit faire exactement 3 caractères")
|
||||
private String currency = "EUR";
|
||||
|
||||
@NotNull(message = "Le début de période de facturation est obligatoire")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
|
||||
private LocalDateTime billingPeriodStart;
|
||||
|
||||
@NotNull(message = "La fin de période de facturation est obligatoire")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
|
||||
private LocalDateTime billingPeriodEnd;
|
||||
|
||||
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
|
||||
private LocalDateTime dueDate;
|
||||
|
||||
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
|
||||
private LocalDateTime paidAt;
|
||||
|
||||
private String invoicePdfUrl;
|
||||
private String hostedInvoiceUrl;
|
||||
|
||||
@Min(value = 0, message = "Le nombre de tentatives doit être positif")
|
||||
private Integer attemptCount = 0;
|
||||
|
||||
// Relations
|
||||
private List<InvoiceLineItemDto> lineItems;
|
||||
|
||||
// Métadonnées
|
||||
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
// ===== PROPRIÉTÉS CALCULÉES =====
|
||||
|
||||
/**
|
||||
* @return true si la facture est payée
|
||||
*/
|
||||
public Boolean isPaid() {
|
||||
return "PAID".equals(status);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true si la facture est en attente de paiement
|
||||
*/
|
||||
public Boolean isPending() {
|
||||
return "OPEN".equals(status);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true si la facture est en retard
|
||||
*/
|
||||
public Boolean isOverdue() {
|
||||
return dueDate != null &&
|
||||
LocalDateTime.now().isAfter(dueDate) &&
|
||||
!isPaid();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return le nombre de jours de retard (0 si pas en retard)
|
||||
*/
|
||||
public Long getDaysOverdue() {
|
||||
if (!isOverdue()) return 0L;
|
||||
|
||||
return java.time.temporal.ChronoUnit.DAYS.between(dueDate, LocalDateTime.now());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return le montant restant à payer
|
||||
*/
|
||||
public BigDecimal getRemainingAmount() {
|
||||
if (amountPaid == null) return amountDue;
|
||||
return amountDue.subtract(amountPaid);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true si la facture est partiellement payée
|
||||
*/
|
||||
public Boolean isPartiallyPaid() {
|
||||
return amountPaid != null &&
|
||||
amountPaid.compareTo(BigDecimal.ZERO) > 0 &&
|
||||
amountPaid.compareTo(amountDue) < 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return le pourcentage de paiement effectué
|
||||
*/
|
||||
public Double getPaymentPercentage() {
|
||||
if (amountDue.compareTo(BigDecimal.ZERO) == 0) return 100.0;
|
||||
if (amountPaid == null) return 0.0;
|
||||
|
||||
return amountPaid.divide(amountDue, 4, java.math.RoundingMode.HALF_UP)
|
||||
.multiply(BigDecimal.valueOf(100))
|
||||
.doubleValue();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return la durée de la période de facturation en jours
|
||||
*/
|
||||
public Long getBillingPeriodDays() {
|
||||
if (billingPeriodStart == null || billingPeriodEnd == null) return null;
|
||||
return java.time.temporal.ChronoUnit.DAYS.between(billingPeriodStart, billingPeriodEnd);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true si cette facture nécessite une attention
|
||||
*/
|
||||
public Boolean requiresAttention() {
|
||||
return isOverdue() ||
|
||||
(attemptCount != null && attemptCount > 3) ||
|
||||
"PAYMENT_FAILED".equals(status);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true si la facture inclut du prorata
|
||||
*/
|
||||
public Boolean hasProrationItems() {
|
||||
return lineItems != null &&
|
||||
lineItems.stream().anyMatch(item ->
|
||||
item.getProrated() != null && item.getProrated());
|
||||
}
|
||||
|
||||
// ===== SOUS-CLASSES POUR RESPONSES SPÉCIFIQUES =====
|
||||
|
||||
/**
|
||||
* Version minimale pour les listes
|
||||
*/
|
||||
@Data
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public static class Summary {
|
||||
private UUID id;
|
||||
private String invoiceNumber;
|
||||
private String status;
|
||||
private BigDecimal amountDue;
|
||||
private BigDecimal amountPaid;
|
||||
private String currency;
|
||||
|
||||
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
|
||||
private LocalDateTime dueDate;
|
||||
|
||||
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
private Boolean isOverdue;
|
||||
private Boolean requiresAttention;
|
||||
private Long daysOverdue;
|
||||
private BigDecimal remainingAmount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Version détaillée pour les vues individuelles
|
||||
*/
|
||||
@Data
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public static class Detailed extends InvoiceDto {
|
||||
// Informations sur l'abonnement
|
||||
private String subscriptionPlan;
|
||||
private String companyName;
|
||||
|
||||
// Actions disponibles
|
||||
private Boolean canDownloadPdf;
|
||||
private Boolean canPayOnline;
|
||||
private Boolean canSendReminder;
|
||||
|
||||
// Historique des paiements
|
||||
private List<PaymentAttemptDto> paymentAttempts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Version publique pour les clients (sans données sensibles)
|
||||
*/
|
||||
@Data
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public static class Customer {
|
||||
private String invoiceNumber;
|
||||
private String status;
|
||||
private BigDecimal amountDue;
|
||||
private BigDecimal amountPaid;
|
||||
private String currency;
|
||||
|
||||
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
|
||||
private LocalDateTime dueDate;
|
||||
|
||||
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
|
||||
private LocalDateTime paidAt;
|
||||
|
||||
private String hostedInvoiceUrl;
|
||||
private List<InvoiceLineItemDto> lineItems;
|
||||
private BigDecimal remainingAmount;
|
||||
private Boolean isOverdue;
|
||||
private Long daysOverdue;
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO pour les tentatives de paiement
|
||||
*/
|
||||
@Data
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public static class PaymentAttemptDto {
|
||||
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
|
||||
private LocalDateTime attemptDate;
|
||||
|
||||
private String status;
|
||||
private String failureReason;
|
||||
private BigDecimal attemptedAmount;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,194 @@
|
||||
package com.dh7789dev.xpeditis.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import jakarta.validation.constraints.*;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* DTO pour les plans d'abonnement dans les réponses API
|
||||
*/
|
||||
@Data
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class PlanDto {
|
||||
|
||||
private UUID id;
|
||||
|
||||
@NotBlank(message = "Le nom du plan est obligatoire")
|
||||
@Size(max = 100, message = "Le nom du plan ne peut pas dépasser 100 caractères")
|
||||
private String name;
|
||||
|
||||
@NotBlank(message = "Le type du plan est obligatoire")
|
||||
@Size(max = 50, message = "Le type du plan ne peut pas dépasser 50 caractères")
|
||||
private String type;
|
||||
|
||||
private String stripePriceIdMonthly;
|
||||
private String stripePriceIdYearly;
|
||||
|
||||
@DecimalMin(value = "0.0", message = "Le prix mensuel doit être positif")
|
||||
@Digits(integer = 8, fraction = 2, message = "Le prix mensuel doit avoir au maximum 8 chiffres avant la virgule et 2 après")
|
||||
private BigDecimal monthlyPrice;
|
||||
|
||||
@DecimalMin(value = "0.0", message = "Le prix annuel doit être positif")
|
||||
@Digits(integer = 8, fraction = 2, message = "Le prix annuel doit avoir au maximum 8 chiffres avant la virgule et 2 après")
|
||||
private BigDecimal yearlyPrice;
|
||||
|
||||
@NotNull(message = "Le nombre maximum d'utilisateurs est obligatoire")
|
||||
@Min(value = -1, message = "Le nombre maximum d'utilisateurs doit être -1 (illimité) ou positif")
|
||||
private Integer maxUsers;
|
||||
|
||||
private Set<String> features;
|
||||
|
||||
@Min(value = 0, message = "La durée d'essai doit être positive")
|
||||
private Integer trialDurationDays = 14;
|
||||
|
||||
private Boolean isActive = true;
|
||||
|
||||
@Min(value = 0, message = "L'ordre d'affichage doit être positif")
|
||||
private Integer displayOrder;
|
||||
|
||||
private Map<String, Object> metadata;
|
||||
|
||||
// Métadonnées
|
||||
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
// ===== PROPRIÉTÉS CALCULÉES =====
|
||||
|
||||
/**
|
||||
* @return true si le plan est disponible pour souscription
|
||||
*/
|
||||
public Boolean isAvailableForSubscription() {
|
||||
return isActive != null && isActive;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true si le plan supporte un nombre illimité d'utilisateurs
|
||||
*/
|
||||
public Boolean hasUnlimitedUsers() {
|
||||
return maxUsers != null && maxUsers == -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return le nombre de fonctionnalités incluses
|
||||
*/
|
||||
public Integer getFeatureCount() {
|
||||
return features != null ? features.size() : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return l'économie annuelle (différence entre 12*mensuel et annuel)
|
||||
*/
|
||||
public BigDecimal getYearlySavings() {
|
||||
if (monthlyPrice == null || yearlyPrice == null) return null;
|
||||
|
||||
BigDecimal yearlyFromMonthly = monthlyPrice.multiply(BigDecimal.valueOf(12));
|
||||
return yearlyFromMonthly.subtract(yearlyPrice);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return le pourcentage d'économie annuelle
|
||||
*/
|
||||
public Double getYearlySavingsPercentage() {
|
||||
BigDecimal savings = getYearlySavings();
|
||||
if (savings == null || monthlyPrice == null || monthlyPrice.compareTo(BigDecimal.ZERO) == 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
BigDecimal yearlyFromMonthly = monthlyPrice.multiply(BigDecimal.valueOf(12));
|
||||
return savings.divide(yearlyFromMonthly, 4, java.math.RoundingMode.HALF_UP)
|
||||
.multiply(BigDecimal.valueOf(100))
|
||||
.doubleValue();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true si ce plan est recommandé
|
||||
*/
|
||||
public Boolean isRecommended() {
|
||||
return metadata != null && Boolean.TRUE.equals(metadata.get("recommended"));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true si ce plan est populaire
|
||||
*/
|
||||
public Boolean isPopular() {
|
||||
return metadata != null && Boolean.TRUE.equals(metadata.get("popular"));
|
||||
}
|
||||
|
||||
// ===== SOUS-CLASSES POUR RESPONSES SPÉCIFIQUES =====
|
||||
|
||||
/**
|
||||
* Version publique pour les pages de pricing (sans IDs Stripe)
|
||||
*/
|
||||
@Data
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public static class Public {
|
||||
private UUID id;
|
||||
private String name;
|
||||
private String type;
|
||||
private BigDecimal monthlyPrice;
|
||||
private BigDecimal yearlyPrice;
|
||||
private Integer maxUsers;
|
||||
private Set<String> features;
|
||||
private Integer trialDurationDays;
|
||||
private Integer displayOrder;
|
||||
private Boolean isRecommended;
|
||||
private Boolean isPopular;
|
||||
private BigDecimal yearlySavings;
|
||||
private Double yearlySavingsPercentage;
|
||||
private Boolean hasUnlimitedUsers;
|
||||
private Integer featureCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Version administrative complète
|
||||
*/
|
||||
@Data
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public static class Admin extends PlanDto {
|
||||
// Statistiques d'utilisation
|
||||
private Long activeSubscriptions;
|
||||
private Long totalSubscriptions;
|
||||
private BigDecimal monthlyRecurringRevenue;
|
||||
private BigDecimal annualRecurringRevenue;
|
||||
|
||||
// Données de gestion
|
||||
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
|
||||
private LocalDateTime lastSyncWithStripe;
|
||||
private Boolean needsStripeSync;
|
||||
}
|
||||
|
||||
/**
|
||||
* Version pour comparaison de plans
|
||||
*/
|
||||
@Data
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public static class Comparison {
|
||||
private UUID id;
|
||||
private String name;
|
||||
private BigDecimal monthlyPrice;
|
||||
private BigDecimal yearlyPrice;
|
||||
private Integer maxUsers;
|
||||
private Set<String> features;
|
||||
private Boolean isRecommended;
|
||||
private Boolean isPopular;
|
||||
private Boolean hasUnlimitedUsers;
|
||||
private Integer featureCount;
|
||||
private BigDecimal yearlySavings;
|
||||
private Double yearlySavingsPercentage;
|
||||
|
||||
// Comparaison relative
|
||||
private String relativePricing; // "cheapest", "most-expensive", "mid-range"
|
||||
private Boolean bestValue;
|
||||
private String targetAudience; // "individual", "small-team", "enterprise"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,168 @@
|
||||
package com.dh7789dev.xpeditis.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* DTO pour les abonnements dans les réponses API
|
||||
*/
|
||||
@Data
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class SubscriptionDto {
|
||||
|
||||
private UUID id;
|
||||
|
||||
@NotBlank(message = "L'ID Stripe de l'abonnement est obligatoire")
|
||||
private String stripeSubscriptionId;
|
||||
|
||||
@NotBlank(message = "L'ID client Stripe est obligatoire")
|
||||
private String stripeCustomerId;
|
||||
|
||||
@NotBlank(message = "L'ID prix Stripe est obligatoire")
|
||||
private String stripePriceId;
|
||||
|
||||
@NotNull(message = "Le statut est obligatoire")
|
||||
private String status;
|
||||
|
||||
@NotNull(message = "Le début de période courante est obligatoire")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
|
||||
private LocalDateTime currentPeriodStart;
|
||||
|
||||
@NotNull(message = "La fin de période courante est obligatoire")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
|
||||
private LocalDateTime currentPeriodEnd;
|
||||
|
||||
private Boolean cancelAtPeriodEnd = false;
|
||||
|
||||
@NotNull(message = "Le cycle de facturation est obligatoire")
|
||||
private String billingCycle;
|
||||
|
||||
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
|
||||
private LocalDateTime nextBillingDate;
|
||||
|
||||
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
|
||||
private LocalDateTime trialEndDate;
|
||||
|
||||
// Relations
|
||||
private LicenseDto license;
|
||||
private List<InvoiceDto> invoices;
|
||||
|
||||
// Métadonnées
|
||||
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
// ===== PROPRIÉTÉS CALCULÉES =====
|
||||
|
||||
/**
|
||||
* @return true si l'abonnement est actif
|
||||
*/
|
||||
public Boolean isActive() {
|
||||
return "ACTIVE".equals(status);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true si l'abonnement est en période d'essai
|
||||
*/
|
||||
public Boolean isTrialing() {
|
||||
return "TRIALING".equals(status);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true si l'abonnement est en retard de paiement
|
||||
*/
|
||||
public Boolean isPastDue() {
|
||||
return "PAST_DUE".equals(status);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true si l'abonnement est annulé
|
||||
*/
|
||||
public Boolean isCanceled() {
|
||||
return "CANCELED".equals(status);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return le nombre de jours jusqu'à la prochaine facturation
|
||||
*/
|
||||
public Long getDaysUntilNextBilling() {
|
||||
if (nextBillingDate == null) return null;
|
||||
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
if (now.isAfter(nextBillingDate)) return 0L;
|
||||
|
||||
return java.time.temporal.ChronoUnit.DAYS.between(now, nextBillingDate);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true si l'abonnement nécessite une attention
|
||||
*/
|
||||
public Boolean requiresAttention() {
|
||||
return "PAST_DUE".equals(status) ||
|
||||
"UNPAID".equals(status) ||
|
||||
"INCOMPLETE".equals(status) ||
|
||||
"INCOMPLETE_EXPIRED".equals(status);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return le nombre de jours restants dans la période d'essai
|
||||
*/
|
||||
public Long getTrialDaysRemaining() {
|
||||
if (!"TRIALING".equals(status) || trialEndDate == null) return null;
|
||||
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
if (now.isAfter(trialEndDate)) return 0L;
|
||||
|
||||
return java.time.temporal.ChronoUnit.DAYS.between(now, trialEndDate);
|
||||
}
|
||||
|
||||
// ===== SOUS-CLASSES POUR RESPONSES SPÉCIFIQUES =====
|
||||
|
||||
/**
|
||||
* Version minimale pour les listes
|
||||
*/
|
||||
@Data
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public static class Summary {
|
||||
private UUID id;
|
||||
private String stripeSubscriptionId;
|
||||
private String status;
|
||||
private String billingCycle;
|
||||
|
||||
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
|
||||
private LocalDateTime nextBillingDate;
|
||||
|
||||
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
private Boolean requiresAttention;
|
||||
private Long daysUntilNextBilling;
|
||||
}
|
||||
|
||||
/**
|
||||
* Version détaillée pour les vues individuelles
|
||||
*/
|
||||
@Data
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public static class Detailed extends SubscriptionDto {
|
||||
// Métriques additionnelles
|
||||
private Integer totalInvoices;
|
||||
private Integer unpaidInvoices;
|
||||
private String planName;
|
||||
private String companyName;
|
||||
|
||||
// Prochaines actions
|
||||
private String nextAction;
|
||||
private LocalDateTime nextActionDate;
|
||||
private String nextActionDescription;
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,91 @@
|
||||
package com.dh7789dev.xpeditis;
|
||||
|
||||
import com.dh7789dev.xpeditis.dto.app.Plan;
|
||||
import com.dh7789dev.xpeditis.dto.app.LicenseType;
|
||||
import com.dh7789dev.xpeditis.dto.app.BillingCycle;
|
||||
import com.dh7789dev.xpeditis.dto.valueobject.Money;
|
||||
import com.dh7789dev.xpeditis.port.in.PlanManagementUseCase;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Service de gestion des plans d'abonnement
|
||||
*/
|
||||
public interface PlanService extends PlanManagementUseCase {
|
||||
|
||||
/**
|
||||
* Récupère tous les plans actifs
|
||||
*/
|
||||
List<Plan> getAllActivePlans();
|
||||
|
||||
/**
|
||||
* Récupère un plan par son ID
|
||||
*/
|
||||
Plan getPlanById(UUID planId);
|
||||
|
||||
/**
|
||||
* Récupère un plan par son type
|
||||
*/
|
||||
Plan getPlanByType(LicenseType type);
|
||||
|
||||
/**
|
||||
* Récupère un plan par son ID Stripe
|
||||
*/
|
||||
Plan getPlanByStripePriceId(String stripePriceId);
|
||||
|
||||
/**
|
||||
* Calcule le montant prorata pour un changement de plan
|
||||
*/
|
||||
Money calculateProrata(Plan currentPlan, Plan newPlan, BillingCycle cycle, int daysRemaining);
|
||||
|
||||
/**
|
||||
* Trouve les plans adaptés à un nombre d'utilisateurs
|
||||
*/
|
||||
List<Plan> findSuitablePlansForUserCount(int userCount);
|
||||
|
||||
/**
|
||||
* Compare deux plans et retourne les différences
|
||||
*/
|
||||
PlanComparison comparePlans(Plan plan1, Plan plan2);
|
||||
|
||||
/**
|
||||
* Vérifie si un upgrade/downgrade est possible
|
||||
*/
|
||||
boolean canChangePlan(Plan currentPlan, Plan targetPlan, int currentUserCount);
|
||||
|
||||
/**
|
||||
* Récupère le plan recommandé pour une entreprise
|
||||
*/
|
||||
Plan getRecommendedPlan(int userCount, List<String> requiredFeatures);
|
||||
|
||||
/**
|
||||
* Classe interne pour la comparaison de plans
|
||||
*/
|
||||
class PlanComparison {
|
||||
private final Plan plan1;
|
||||
private final Plan plan2;
|
||||
private final List<String> addedFeatures;
|
||||
private final List<String> removedFeatures;
|
||||
private final Money priceDifference;
|
||||
private final boolean isUpgrade;
|
||||
|
||||
public PlanComparison(Plan plan1, Plan plan2, List<String> addedFeatures,
|
||||
List<String> removedFeatures, Money priceDifference, boolean isUpgrade) {
|
||||
this.plan1 = plan1;
|
||||
this.plan2 = plan2;
|
||||
this.addedFeatures = addedFeatures;
|
||||
this.removedFeatures = removedFeatures;
|
||||
this.priceDifference = priceDifference;
|
||||
this.isUpgrade = isUpgrade;
|
||||
}
|
||||
|
||||
// Getters
|
||||
public Plan getPlan1() { return plan1; }
|
||||
public Plan getPlan2() { return plan2; }
|
||||
public List<String> getAddedFeatures() { return addedFeatures; }
|
||||
public List<String> getRemovedFeatures() { return removedFeatures; }
|
||||
public Money getPriceDifference() { return priceDifference; }
|
||||
public boolean isUpgrade() { return isUpgrade; }
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,71 @@
|
||||
package com.dh7789dev.xpeditis;
|
||||
|
||||
import com.dh7789dev.xpeditis.dto.app.Subscription;
|
||||
import com.dh7789dev.xpeditis.dto.app.SubscriptionStatus;
|
||||
import com.dh7789dev.xpeditis.dto.app.Plan;
|
||||
import com.dh7789dev.xpeditis.dto.app.BillingCycle;
|
||||
import com.dh7789dev.xpeditis.port.in.SubscriptionManagementUseCase;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Service de gestion des abonnements avec intégration Stripe
|
||||
*/
|
||||
public interface SubscriptionService extends SubscriptionManagementUseCase {
|
||||
|
||||
/**
|
||||
* Crée un abonnement Stripe et la licence associée
|
||||
*/
|
||||
Subscription createSubscription(UUID companyId, Plan plan, BillingCycle billingCycle);
|
||||
|
||||
/**
|
||||
* Met à jour un abonnement depuis un webhook Stripe
|
||||
*/
|
||||
Subscription updateSubscriptionFromStripe(String stripeSubscriptionId, SubscriptionStatus newStatus);
|
||||
|
||||
/**
|
||||
* Change le plan d'un abonnement
|
||||
*/
|
||||
Subscription changePlan(UUID companyId, Plan newPlan, boolean immediate);
|
||||
|
||||
/**
|
||||
* Annule un abonnement
|
||||
*/
|
||||
Subscription cancelSubscription(UUID companyId, boolean immediate, String reason);
|
||||
|
||||
/**
|
||||
* Réactive un abonnement annulé
|
||||
*/
|
||||
Subscription reactivateSubscription(UUID companyId);
|
||||
|
||||
/**
|
||||
* Récupère l'abonnement actif d'une entreprise
|
||||
*/
|
||||
Subscription getActiveSubscription(UUID companyId);
|
||||
|
||||
/**
|
||||
* Traite un échec de paiement
|
||||
*/
|
||||
void handlePaymentFailure(String stripeSubscriptionId, String reason);
|
||||
|
||||
/**
|
||||
* Traite un paiement réussi
|
||||
*/
|
||||
void handlePaymentSuccess(String stripeSubscriptionId, String stripeInvoiceId);
|
||||
|
||||
/**
|
||||
* Récupère les abonnements nécessitant une attention
|
||||
*/
|
||||
List<Subscription> getSubscriptionsRequiringAttention();
|
||||
|
||||
/**
|
||||
* Démarre la période de grâce pour un abonnement
|
||||
*/
|
||||
void startGracePeriod(UUID subscriptionId);
|
||||
|
||||
/**
|
||||
* Suspend les abonnements impayés
|
||||
*/
|
||||
void suspendUnpaidSubscriptions();
|
||||
}
|
||||
@ -0,0 +1,33 @@
|
||||
package com.dh7789dev.xpeditis.port.in;
|
||||
|
||||
import com.dh7789dev.xpeditis.dto.app.Plan;
|
||||
import com.dh7789dev.xpeditis.dto.app.LicenseType;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Port d'entrée pour la gestion des plans d'abonnement
|
||||
*/
|
||||
public interface PlanManagementUseCase {
|
||||
|
||||
/**
|
||||
* Récupère tous les plans disponibles
|
||||
*/
|
||||
List<Plan> getAllActivePlans();
|
||||
|
||||
/**
|
||||
* Récupère un plan par son ID
|
||||
*/
|
||||
Plan getPlanById(UUID planId);
|
||||
|
||||
/**
|
||||
* Récupère un plan par son type de licence
|
||||
*/
|
||||
Plan getPlanByType(LicenseType type);
|
||||
|
||||
/**
|
||||
* Trouve les plans adaptés à un nombre d'utilisateurs
|
||||
*/
|
||||
List<Plan> findSuitablePlansForUserCount(int userCount);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -0,0 +1,133 @@
|
||||
package com.dh7789dev.xpeditis.dto.app;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Facture générée pour un abonnement
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class Invoice {
|
||||
private UUID id;
|
||||
private String stripeInvoiceId;
|
||||
private String invoiceNumber;
|
||||
private Subscription subscription;
|
||||
private InvoiceStatus status;
|
||||
private BigDecimal amountDue;
|
||||
private BigDecimal amountPaid;
|
||||
private String currency;
|
||||
private LocalDateTime billingPeriodStart;
|
||||
private LocalDateTime billingPeriodEnd;
|
||||
private LocalDateTime dueDate;
|
||||
private LocalDateTime paidAt;
|
||||
private String invoicePdfUrl;
|
||||
private String hostedInvoiceUrl;
|
||||
private Integer attemptCount;
|
||||
private List<InvoiceLineItem> lineItems;
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
/**
|
||||
* @return true si la facture est payée
|
||||
*/
|
||||
public boolean isPaid() {
|
||||
return status == InvoiceStatus.PAID;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true si la facture est en attente de paiement
|
||||
*/
|
||||
public boolean isOpen() {
|
||||
return status == InvoiceStatus.OPEN;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true si la facture est en retard
|
||||
*/
|
||||
public boolean isOverdue() {
|
||||
return isOpen() && dueDate != null && LocalDateTime.now().isAfter(dueDate);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true si la facture est annulée
|
||||
*/
|
||||
public boolean isVoided() {
|
||||
return status == InvoiceStatus.VOID;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return le montant restant à payer
|
||||
*/
|
||||
public BigDecimal getAmountRemaining() {
|
||||
if (amountDue == null) return BigDecimal.ZERO;
|
||||
if (amountPaid == null) return amountDue;
|
||||
return amountDue.subtract(amountPaid);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true si la facture est entièrement payée
|
||||
*/
|
||||
public boolean isFullyPaid() {
|
||||
return getAmountRemaining().compareTo(BigDecimal.ZERO) <= 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return le nombre de jours depuis la date d'échéance
|
||||
*/
|
||||
public long getDaysOverdue() {
|
||||
if (dueDate == null || !isOverdue()) return 0;
|
||||
return java.time.temporal.ChronoUnit.DAYS.between(dueDate, LocalDateTime.now());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return le nombre de jours jusqu'à l'échéance (négatif si en retard)
|
||||
*/
|
||||
public long getDaysUntilDue() {
|
||||
if (dueDate == null) return Long.MAX_VALUE;
|
||||
return java.time.temporal.ChronoUnit.DAYS.between(LocalDateTime.now(), dueDate);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return la période de facturation au format texte
|
||||
*/
|
||||
public String getBillingPeriodDescription() {
|
||||
if (billingPeriodStart == null || billingPeriodEnd == null) {
|
||||
return "Période inconnue";
|
||||
}
|
||||
|
||||
java.time.format.DateTimeFormatter formatter = java.time.format.DateTimeFormatter.ofPattern("dd/MM/yyyy");
|
||||
return "Du " + billingPeriodStart.format(formatter) + " au " + billingPeriodEnd.format(formatter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Incrémente le compteur de tentatives de paiement
|
||||
*/
|
||||
public void incrementAttemptCount() {
|
||||
this.attemptCount = (attemptCount == null ? 0 : attemptCount) + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Marque la facture comme payée
|
||||
*/
|
||||
public void markAsPaid(LocalDateTime paidAt, BigDecimal amountPaid) {
|
||||
this.status = InvoiceStatus.PAID;
|
||||
this.paidAt = paidAt;
|
||||
this.amountPaid = amountPaid;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true si cette facture nécessite une attention immédiate
|
||||
*/
|
||||
public boolean requiresAttention() {
|
||||
return isOverdue() || (isOpen() && getDaysUntilDue() <= 3);
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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,15 +18,24 @@ 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<String> featuresEnabled;
|
||||
private boolean isActive;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
private LocalDateTime lastCheckedAt;
|
||||
private Company company;
|
||||
private Subscription subscription;
|
||||
|
||||
/**
|
||||
* @return true si la licence est expirée (date d'expiration dépassée)
|
||||
*/
|
||||
public boolean isExpired() {
|
||||
return expirationDate != null && expirationDate.isBefore(LocalDate.now());
|
||||
}
|
||||
@ -34,21 +44,158 @@ public class License {
|
||||
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) :
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,161 @@
|
||||
package com.dh7789dev.xpeditis.dto.app;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Événement de paiement reçu via webhook Stripe
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class PaymentEvent {
|
||||
private UUID id;
|
||||
private String stripeEventId;
|
||||
private String eventType;
|
||||
private EventStatus status;
|
||||
private Map<String, Object> payload;
|
||||
private LocalDateTime processedAt;
|
||||
private String errorMessage;
|
||||
private Integer retryCount;
|
||||
private Subscription subscription;
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
/**
|
||||
* @return true si l'événement a été traité avec succès
|
||||
*/
|
||||
public boolean isProcessed() {
|
||||
return status == EventStatus.PROCESSED;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true si l'événement a échoué
|
||||
*/
|
||||
public boolean isFailed() {
|
||||
return status == EventStatus.FAILED;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true si l'événement est en attente de traitement
|
||||
*/
|
||||
public boolean isPending() {
|
||||
return status == EventStatus.PENDING;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true si l'événement est en cours de traitement
|
||||
*/
|
||||
public boolean isProcessing() {
|
||||
return status == EventStatus.PROCESSING;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true si l'événement peut être réessayé
|
||||
*/
|
||||
public boolean canRetry() {
|
||||
return isFailed() && (retryCount == null || retryCount < 5);
|
||||
}
|
||||
|
||||
/**
|
||||
* Incrémente le compteur de tentatives
|
||||
*/
|
||||
public void incrementRetryCount() {
|
||||
this.retryCount = (retryCount == null ? 0 : retryCount) + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Marque l'événement comme traité avec succès
|
||||
*/
|
||||
public void markAsProcessed() {
|
||||
this.status = EventStatus.PROCESSED;
|
||||
this.processedAt = LocalDateTime.now();
|
||||
this.errorMessage = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Marque l'événement comme échoué
|
||||
*/
|
||||
public void markAsFailed(String errorMessage) {
|
||||
this.status = EventStatus.FAILED;
|
||||
this.errorMessage = errorMessage;
|
||||
incrementRetryCount();
|
||||
}
|
||||
|
||||
/**
|
||||
* Marque l'événement comme en cours de traitement
|
||||
*/
|
||||
public void markAsProcessing() {
|
||||
this.status = EventStatus.PROCESSING;
|
||||
}
|
||||
|
||||
/**
|
||||
* Marque l'événement comme ignoré
|
||||
*/
|
||||
public void markAsIgnored() {
|
||||
this.status = EventStatus.IGNORED;
|
||||
this.processedAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true si c'est un événement concernant une facture
|
||||
*/
|
||||
public boolean isInvoiceEvent() {
|
||||
return eventType != null && eventType.startsWith("invoice.");
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true si c'est un événement concernant un abonnement
|
||||
*/
|
||||
public boolean isSubscriptionEvent() {
|
||||
return eventType != null && eventType.startsWith("customer.subscription.");
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true si c'est un événement concernant un paiement
|
||||
*/
|
||||
public boolean isPaymentEvent() {
|
||||
return eventType != null && (
|
||||
eventType.startsWith("payment_intent.") ||
|
||||
eventType.startsWith("invoice.payment_")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return une description lisible de l'événement
|
||||
*/
|
||||
public String getEventDescription() {
|
||||
if (eventType == null) return "Événement inconnu";
|
||||
|
||||
switch (eventType) {
|
||||
case "customer.subscription.created":
|
||||
return "Abonnement créé";
|
||||
case "customer.subscription.updated":
|
||||
return "Abonnement modifié";
|
||||
case "customer.subscription.deleted":
|
||||
return "Abonnement supprimé";
|
||||
case "invoice.payment_succeeded":
|
||||
return "Paiement réussi";
|
||||
case "invoice.payment_failed":
|
||||
return "Paiement échoué";
|
||||
case "invoice.created":
|
||||
return "Facture créée";
|
||||
case "invoice.finalized":
|
||||
return "Facture finalisée";
|
||||
case "checkout.session.completed":
|
||||
return "Session de paiement complétée";
|
||||
case "payment_method.attached":
|
||||
return "Méthode de paiement ajoutée";
|
||||
case "payment_method.detached":
|
||||
return "Méthode de paiement supprimée";
|
||||
default:
|
||||
return eventType;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -0,0 +1,94 @@
|
||||
package com.dh7789dev.xpeditis.dto.app;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Plan d'abonnement avec tarification et fonctionnalités
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class Plan {
|
||||
private UUID id;
|
||||
private String name;
|
||||
private LicenseType type;
|
||||
private String stripePriceIdMonthly;
|
||||
private String stripePriceIdYearly;
|
||||
private BigDecimal monthlyPrice;
|
||||
private BigDecimal yearlyPrice;
|
||||
private Integer maxUsers;
|
||||
private Set<String> features;
|
||||
private Integer trialDurationDays;
|
||||
private boolean isActive;
|
||||
private Integer displayOrder;
|
||||
private Map<String, String> metadata;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
/**
|
||||
* @return Le prix pour le cycle de facturation spécifié
|
||||
*/
|
||||
public BigDecimal getPriceForCycle(BillingCycle cycle) {
|
||||
return cycle.isMonthly() ? monthlyPrice : yearlyPrice;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return L'ID de prix Stripe pour le cycle spécifié
|
||||
*/
|
||||
public String getStripePriceIdForCycle(BillingCycle cycle) {
|
||||
return cycle.isMonthly() ? stripePriceIdMonthly : stripePriceIdYearly;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true si ce plan supporte les trials
|
||||
*/
|
||||
public boolean supportsTrials() {
|
||||
return trialDurationDays != null && trialDurationDays > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true si ce plan a une limite d'utilisateurs
|
||||
*/
|
||||
public boolean hasUserLimit() {
|
||||
return type != null && type.hasUserLimit() && maxUsers != null && maxUsers > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true si ce plan inclut la fonctionnalité spécifiée
|
||||
*/
|
||||
public boolean hasFeature(String feature) {
|
||||
return features != null && features.contains(feature);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Le prix mensuel équivalent (même pour les plans annuels)
|
||||
*/
|
||||
public BigDecimal getMonthlyEquivalentPrice(BillingCycle cycle) {
|
||||
BigDecimal price = getPriceForCycle(cycle);
|
||||
return cycle.isYearly() ? price.divide(BigDecimal.valueOf(12), 2, java.math.RoundingMode.HALF_UP) : price;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Le pourcentage d'économies sur le plan annuel vs mensuel
|
||||
*/
|
||||
public BigDecimal getYearlySavingsPercentage() {
|
||||
if (monthlyPrice == null || yearlyPrice == null || monthlyPrice.compareTo(BigDecimal.ZERO) == 0) {
|
||||
return BigDecimal.ZERO;
|
||||
}
|
||||
|
||||
BigDecimal yearlyEquivalent = monthlyPrice.multiply(BigDecimal.valueOf(12));
|
||||
BigDecimal savings = yearlyEquivalent.subtract(yearlyPrice);
|
||||
return savings.divide(yearlyEquivalent, 4, java.math.RoundingMode.HALF_UP)
|
||||
.multiply(BigDecimal.valueOf(100));
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,145 @@
|
||||
package com.dh7789dev.xpeditis.dto.app;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Abonnement d'une entreprise avec intégration Stripe
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class Subscription {
|
||||
private UUID id;
|
||||
private String stripeSubscriptionId;
|
||||
private String stripeCustomerId;
|
||||
private String stripePriceId;
|
||||
private SubscriptionStatus status;
|
||||
private LocalDateTime currentPeriodStart;
|
||||
private LocalDateTime currentPeriodEnd;
|
||||
private boolean cancelAtPeriodEnd;
|
||||
private PaymentMethod paymentMethod;
|
||||
private BillingCycle billingCycle;
|
||||
private LocalDateTime nextBillingDate;
|
||||
private LocalDateTime trialEndDate;
|
||||
private License license;
|
||||
private List<Invoice> invoices;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
/**
|
||||
* @return true si l'abonnement est actif
|
||||
*/
|
||||
public boolean isActive() {
|
||||
return status == SubscriptionStatus.ACTIVE;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true si l'abonnement est en période d'essai
|
||||
*/
|
||||
public boolean isTrialing() {
|
||||
return status == SubscriptionStatus.TRIALING;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true si l'abonnement est en retard de paiement
|
||||
*/
|
||||
public boolean isPastDue() {
|
||||
return status == SubscriptionStatus.PAST_DUE;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true si l'abonnement est annulé
|
||||
*/
|
||||
public boolean isCanceled() {
|
||||
return status == SubscriptionStatus.CANCELED;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true si l'abonnement est programmé pour annulation
|
||||
*/
|
||||
public boolean isScheduledForCancellation() {
|
||||
return cancelAtPeriodEnd && isActive();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true si l'abonnement est encore dans la période d'essai
|
||||
*/
|
||||
public boolean isInTrialPeriod() {
|
||||
return trialEndDate != null && LocalDateTime.now().isBefore(trialEndDate);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return le nombre de jours restants dans la période d'essai
|
||||
*/
|
||||
public long getDaysRemainingInTrial() {
|
||||
if (trialEndDate == null) return 0;
|
||||
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
if (now.isAfter(trialEndDate)) return 0;
|
||||
|
||||
return java.time.temporal.ChronoUnit.DAYS.between(now, trialEndDate);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return le nombre de jours jusqu'à la prochaine facturation
|
||||
*/
|
||||
public long getDaysUntilNextBilling() {
|
||||
if (nextBillingDate == null) return Long.MAX_VALUE;
|
||||
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
if (now.isAfter(nextBillingDate)) return 0;
|
||||
|
||||
return java.time.temporal.ChronoUnit.DAYS.between(now, nextBillingDate);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true si l'abonnement nécessite une attention (paiement échoué, etc.)
|
||||
*/
|
||||
public boolean requiresAttention() {
|
||||
return status == SubscriptionStatus.PAST_DUE
|
||||
|| status == SubscriptionStatus.UNPAID
|
||||
|| status == SubscriptionStatus.INCOMPLETE
|
||||
|| status == SubscriptionStatus.INCOMPLETE_EXPIRED;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true si l'abonnement peut être réactivé
|
||||
*/
|
||||
public boolean canBeReactivated() {
|
||||
return status == SubscriptionStatus.CANCELED
|
||||
|| status == SubscriptionStatus.PAST_DUE
|
||||
|| status == SubscriptionStatus.UNPAID;
|
||||
}
|
||||
|
||||
/**
|
||||
* Met à jour le statut de l'abonnement
|
||||
*/
|
||||
public void updateStatus(SubscriptionStatus newStatus) {
|
||||
this.status = newStatus;
|
||||
this.updatedAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Programme l'annulation de l'abonnement à la fin de la période
|
||||
*/
|
||||
public void scheduleForCancellation() {
|
||||
this.cancelAtPeriodEnd = true;
|
||||
this.updatedAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Annule la programmation d'annulation
|
||||
*/
|
||||
public void unscheduleForCancellation() {
|
||||
this.cancelAtPeriodEnd = false;
|
||||
this.updatedAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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<String> trialFeatures = Set.of(
|
||||
PlanFeature.Features.BASIC_QUOTES,
|
||||
PlanFeature.Features.DOCUMENT_TEMPLATES
|
||||
);
|
||||
|
||||
// Créer la licence trial
|
||||
License trialLicense = License.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.licenseKey(generateLicenseKey("TRIAL"))
|
||||
.type(LicenseType.TRIAL)
|
||||
.status(LicenseStatus.ACTIVE)
|
||||
.startDate(LocalDate.now())
|
||||
.expirationDate(LocalDate.now().plusDays(LicenseType.TRIAL.getDurationDays()))
|
||||
.maxUsers(LicenseType.TRIAL.getMaxUsers())
|
||||
.featuresEnabled(trialFeatures)
|
||||
.isActive(true)
|
||||
.company(company)
|
||||
.createdAt(LocalDateTime.now())
|
||||
.updatedAt(LocalDateTime.now())
|
||||
.build();
|
||||
|
||||
return licenseRepository.save(trialLicense);
|
||||
}
|
||||
|
||||
@Override
|
||||
@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<String> newFeatures = getFeaturesForLicenseType(newType);
|
||||
|
||||
// Mettre à jour la licence existante
|
||||
currentLicense.setType(newType);
|
||||
currentLicense.setMaxUsers(newType.getMaxUsers());
|
||||
currentLicense.setFeaturesEnabled(newFeatures);
|
||||
currentLicense.setUpdatedAt(LocalDateTime.now());
|
||||
|
||||
// Pour un upgrade vers un plan payant, on supprime la date d'expiration
|
||||
if (newType != LicenseType.TRIAL) {
|
||||
currentLicense.setExpirationDate(null);
|
||||
}
|
||||
|
||||
return licenseRepository.save(currentLicense);
|
||||
}
|
||||
|
||||
@Override
|
||||
@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<String> getFeaturesForLicenseType(LicenseType type) {
|
||||
switch (type) {
|
||||
case TRIAL:
|
||||
return Set.of(
|
||||
PlanFeature.Features.BASIC_QUOTES,
|
||||
PlanFeature.Features.DOCUMENT_TEMPLATES
|
||||
);
|
||||
case BASIC:
|
||||
return Set.of(
|
||||
PlanFeature.Features.BASIC_QUOTES,
|
||||
PlanFeature.Features.DOCUMENT_TEMPLATES,
|
||||
PlanFeature.Features.MULTI_CURRENCY,
|
||||
PlanFeature.Features.TEAM_COLLABORATION
|
||||
);
|
||||
case PREMIUM:
|
||||
return Set.of(
|
||||
PlanFeature.Features.BASIC_QUOTES,
|
||||
PlanFeature.Features.DOCUMENT_TEMPLATES,
|
||||
PlanFeature.Features.MULTI_CURRENCY,
|
||||
PlanFeature.Features.TEAM_COLLABORATION,
|
||||
PlanFeature.Features.ADVANCED_ANALYTICS,
|
||||
PlanFeature.Features.BULK_IMPORT,
|
||||
PlanFeature.Features.API_ACCESS,
|
||||
PlanFeature.Features.PRIORITY_SUPPORT,
|
||||
PlanFeature.Features.AUTOMATED_WORKFLOWS
|
||||
);
|
||||
case ENTERPRISE:
|
||||
return Set.of(
|
||||
PlanFeature.Features.BASIC_QUOTES,
|
||||
PlanFeature.Features.DOCUMENT_TEMPLATES,
|
||||
PlanFeature.Features.MULTI_CURRENCY,
|
||||
PlanFeature.Features.TEAM_COLLABORATION,
|
||||
PlanFeature.Features.ADVANCED_ANALYTICS,
|
||||
PlanFeature.Features.BULK_IMPORT,
|
||||
PlanFeature.Features.API_ACCESS,
|
||||
PlanFeature.Features.PRIORITY_SUPPORT,
|
||||
PlanFeature.Features.AUTOMATED_WORKFLOWS,
|
||||
PlanFeature.Features.CUSTOM_BRANDING,
|
||||
PlanFeature.Features.CUSTOM_INTEGRATIONS,
|
||||
PlanFeature.Features.AUDIT_LOGS,
|
||||
PlanFeature.Features.SLA_GUARANTEE,
|
||||
PlanFeature.Features.DEDICATED_SUPPORT
|
||||
);
|
||||
default:
|
||||
return Set.of();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,171 @@
|
||||
package com.dh7789dev.xpeditis;
|
||||
|
||||
import com.dh7789dev.xpeditis.dto.app.Plan;
|
||||
import com.dh7789dev.xpeditis.dto.app.LicenseType;
|
||||
import com.dh7789dev.xpeditis.dto.app.BillingCycle;
|
||||
import com.dh7789dev.xpeditis.dto.valueobject.Money;
|
||||
import com.dh7789dev.xpeditis.port.out.PlanRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.ArrayList;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Implémentation du service de gestion des plans
|
||||
*/
|
||||
@Service
|
||||
public class PlanServiceImpl implements PlanService {
|
||||
|
||||
private final PlanRepository planRepository;
|
||||
|
||||
public PlanServiceImpl(PlanRepository planRepository) {
|
||||
this.planRepository = planRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Plan> getAllActivePlans() {
|
||||
return planRepository.findAllByIsActiveTrue();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Plan getPlanById(UUID planId) {
|
||||
return planRepository.findById(planId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Plan non trouvé: " + planId));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Plan getPlanByType(LicenseType type) {
|
||||
return planRepository.findByTypeAndIsActiveTrue(type)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Plan non trouvé pour le type: " + type));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Plan getPlanByStripePriceId(String stripePriceId) {
|
||||
return planRepository.findByStripePriceId(stripePriceId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Plan non trouvé pour l'ID Stripe: " + stripePriceId));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Money calculateProrata(Plan currentPlan, Plan newPlan, BillingCycle cycle, int daysRemaining) {
|
||||
if (currentPlan.equals(newPlan)) {
|
||||
return Money.zeroEuros();
|
||||
}
|
||||
|
||||
BigDecimal currentPrice = currentPlan.getPriceForCycle(cycle);
|
||||
BigDecimal newPrice = newPlan.getPriceForCycle(cycle);
|
||||
|
||||
int totalDaysInCycle = cycle.getMonths() * 30; // Approximation
|
||||
|
||||
// Calcul du crédit pour la période restante du plan actuel
|
||||
BigDecimal dailyCurrentRate = currentPrice.divide(BigDecimal.valueOf(totalDaysInCycle), 4, RoundingMode.HALF_UP);
|
||||
BigDecimal credit = dailyCurrentRate.multiply(BigDecimal.valueOf(daysRemaining));
|
||||
|
||||
// Calcul du coût pour la période restante du nouveau plan
|
||||
BigDecimal dailyNewRate = newPrice.divide(BigDecimal.valueOf(totalDaysInCycle), 4, RoundingMode.HALF_UP);
|
||||
BigDecimal newCost = dailyNewRate.multiply(BigDecimal.valueOf(daysRemaining));
|
||||
|
||||
// Montant à payer = nouveau coût - crédit
|
||||
BigDecimal amountDue = newCost.subtract(credit);
|
||||
|
||||
return Money.euros(amountDue);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Plan> findSuitablePlansForUserCount(int userCount) {
|
||||
return planRepository.findSuitableForUserCount(userCount);
|
||||
}
|
||||
|
||||
@Override
|
||||
public PlanComparison comparePlans(Plan plan1, Plan plan2) {
|
||||
Set<String> features1 = plan1.getFeatures() != null ? plan1.getFeatures() : Set.of();
|
||||
Set<String> features2 = plan2.getFeatures() != null ? plan2.getFeatures() : Set.of();
|
||||
|
||||
// Fonctionnalités ajoutées (dans plan2 mais pas dans plan1)
|
||||
List<String> addedFeatures = features2.stream()
|
||||
.filter(feature -> !features1.contains(feature))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
// Fonctionnalités supprimées (dans plan1 mais pas dans plan2)
|
||||
List<String> removedFeatures = features1.stream()
|
||||
.filter(feature -> !features2.contains(feature))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
// Différence de prix (mensuel)
|
||||
BigDecimal priceDiff = plan2.getMonthlyPrice().subtract(plan1.getMonthlyPrice());
|
||||
Money priceDifference = Money.euros(priceDiff);
|
||||
|
||||
// Déterminer si c'est un upgrade
|
||||
boolean isUpgrade = isUpgrade(plan1, plan2);
|
||||
|
||||
return new PlanComparison(plan1, plan2, addedFeatures, removedFeatures, priceDifference, isUpgrade);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean canChangePlan(Plan currentPlan, Plan targetPlan, int currentUserCount) {
|
||||
// Vérifier si le plan cible peut supporter le nombre d'utilisateurs actuel
|
||||
if (targetPlan.hasUserLimit() && targetPlan.getMaxUsers() != null
|
||||
&& currentUserCount > targetPlan.getMaxUsers()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Vérifier si c'est un downgrade valide (pas de perte de fonctionnalités critiques utilisées)
|
||||
if (!isUpgrade(currentPlan, targetPlan)) {
|
||||
// Pour un downgrade, on pourrait vérifier l'utilisation des fonctionnalités
|
||||
// Ici on autorise tous les downgrades pour simplifier
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Plan getRecommendedPlan(int userCount, List<String> requiredFeatures) {
|
||||
List<Plan> allPlans = getAllActivePlans();
|
||||
|
||||
return allPlans.stream()
|
||||
.filter(plan -> {
|
||||
// Le plan doit supporter le nombre d'utilisateurs
|
||||
if (plan.hasUserLimit() && plan.getMaxUsers() != null && userCount > plan.getMaxUsers()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Le plan doit inclure toutes les fonctionnalités requises
|
||||
Set<String> planFeatures = plan.getFeatures() != null ? plan.getFeatures() : Set.of();
|
||||
return planFeatures.containsAll(requiredFeatures);
|
||||
})
|
||||
.min((p1, p2) -> {
|
||||
// Prendre le plan le moins cher qui satisfait les critères
|
||||
return p1.getMonthlyPrice().compareTo(p2.getMonthlyPrice());
|
||||
})
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Détermine si le changement de plan1 vers plan2 est un upgrade
|
||||
*/
|
||||
private boolean isUpgrade(Plan plan1, Plan plan2) {
|
||||
// Basé sur l'ordre des types de licence
|
||||
int order1 = getLicenseTypeOrder(plan1.getType());
|
||||
int order2 = getLicenseTypeOrder(plan2.getType());
|
||||
|
||||
return order2 > order1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne l'ordre du type de licence pour déterminer les upgrades
|
||||
*/
|
||||
private int getLicenseTypeOrder(LicenseType type) {
|
||||
switch (type) {
|
||||
case TRIAL: return 0;
|
||||
case BASIC: return 1;
|
||||
case PREMIUM: return 2;
|
||||
case ENTERPRISE: return 3;
|
||||
default: return -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,263 @@
|
||||
package com.dh7789dev.xpeditis;
|
||||
|
||||
import com.dh7789dev.xpeditis.dto.app.*;
|
||||
import com.dh7789dev.xpeditis.port.out.SubscriptionRepository;
|
||||
import com.dh7789dev.xpeditis.port.out.InvoiceRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Implémentation du service de gestion des abonnements
|
||||
*/
|
||||
@Service
|
||||
public class SubscriptionServiceImpl implements SubscriptionService {
|
||||
|
||||
private final SubscriptionRepository subscriptionRepository;
|
||||
private final InvoiceRepository invoiceRepository;
|
||||
private final LicenseService licenseService;
|
||||
private static final int GRACE_PERIOD_DAYS = 7;
|
||||
|
||||
public SubscriptionServiceImpl(
|
||||
SubscriptionRepository subscriptionRepository,
|
||||
InvoiceRepository invoiceRepository,
|
||||
LicenseService licenseService
|
||||
) {
|
||||
this.subscriptionRepository = subscriptionRepository;
|
||||
this.invoiceRepository = invoiceRepository;
|
||||
this.licenseService = licenseService;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public Subscription createSubscription(UUID companyId, Plan plan, BillingCycle billingCycle) {
|
||||
// Vérifier qu'il n'y a pas déjà un abonnement actif
|
||||
Subscription existingSubscription = getActiveSubscription(companyId);
|
||||
if (existingSubscription != null) {
|
||||
throw new IllegalStateException("Une entreprise ne peut avoir qu'un seul abonnement actif");
|
||||
}
|
||||
|
||||
// Créer l'abonnement
|
||||
Subscription subscription = Subscription.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.status(SubscriptionStatus.INCOMPLETE) // En attente de la création Stripe
|
||||
.billingCycle(billingCycle)
|
||||
.currentPeriodStart(LocalDateTime.now())
|
||||
.currentPeriodEnd(LocalDateTime.now().plusDays(billingCycle.getMonths() * 30L))
|
||||
.cancelAtPeriodEnd(false)
|
||||
.createdAt(LocalDateTime.now())
|
||||
.updatedAt(LocalDateTime.now())
|
||||
.build();
|
||||
|
||||
// Calculer la prochaine date de facturation
|
||||
subscription.setNextBillingDate(subscription.getCurrentPeriodEnd());
|
||||
|
||||
return subscriptionRepository.save(subscription);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public Subscription updateSubscriptionFromStripe(String stripeSubscriptionId, SubscriptionStatus newStatus) {
|
||||
Subscription subscription = subscriptionRepository.findByStripeSubscriptionId(stripeSubscriptionId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Abonnement Stripe non trouvé: " + stripeSubscriptionId));
|
||||
|
||||
SubscriptionStatus oldStatus = subscription.getStatus();
|
||||
subscription.updateStatus(newStatus);
|
||||
|
||||
// Gérer les transitions d'état
|
||||
handleStatusTransition(subscription, oldStatus, newStatus);
|
||||
|
||||
return subscriptionRepository.save(subscription);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public Subscription changePlan(UUID companyId, Plan newPlan, boolean immediate) {
|
||||
Subscription subscription = getActiveSubscription(companyId);
|
||||
if (subscription == null) {
|
||||
throw new IllegalArgumentException("Aucun abonnement actif trouvé pour cette entreprise");
|
||||
}
|
||||
|
||||
// Mettre à jour l'abonnement
|
||||
subscription.setStripePriceId(newPlan.getStripePriceIdForCycle(subscription.getBillingCycle()));
|
||||
subscription.setUpdatedAt(LocalDateTime.now());
|
||||
|
||||
// Si c'est immédiat, mettre à jour la licence aussi
|
||||
if (immediate && subscription.getLicense() != null) {
|
||||
licenseService.upgradeLicense(companyId, newPlan.getType());
|
||||
}
|
||||
|
||||
return subscriptionRepository.save(subscription);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public Subscription cancelSubscription(UUID companyId, boolean immediate, String reason) {
|
||||
Subscription subscription = getActiveSubscription(companyId);
|
||||
if (subscription == null) {
|
||||
throw new IllegalArgumentException("Aucun abonnement actif trouvé pour cette entreprise");
|
||||
}
|
||||
|
||||
if (immediate) {
|
||||
subscription.updateStatus(SubscriptionStatus.CANCELED);
|
||||
// Suspendre la licence immédiatement
|
||||
if (subscription.getLicense() != null) {
|
||||
licenseService.deactivateLicense(subscription.getLicense().getId());
|
||||
}
|
||||
} else {
|
||||
// Programmer l'annulation à la fin de la période
|
||||
subscription.scheduleForCancellation();
|
||||
}
|
||||
|
||||
return subscriptionRepository.save(subscription);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public Subscription reactivateSubscription(UUID companyId) {
|
||||
// Chercher un abonnement annulé ou suspendu
|
||||
List<Subscription> subscriptions = subscriptionRepository.findByCompanyId(companyId);
|
||||
Subscription subscription = subscriptions.stream()
|
||||
.filter(s -> s.canBeReactivated())
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new IllegalArgumentException("Aucun abonnement réactivable trouvé"));
|
||||
|
||||
subscription.updateStatus(SubscriptionStatus.ACTIVE);
|
||||
subscription.unscheduleForCancellation();
|
||||
|
||||
// Réactiver la licence
|
||||
if (subscription.getLicense() != null) {
|
||||
subscription.getLicense().reactivate();
|
||||
}
|
||||
|
||||
return subscriptionRepository.save(subscription);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Subscription getActiveSubscription(UUID companyId) {
|
||||
return subscriptionRepository.findActiveByCompanyId(companyId)
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void handlePaymentFailure(String stripeSubscriptionId, String reason) {
|
||||
Subscription subscription = subscriptionRepository.findByStripeSubscriptionId(stripeSubscriptionId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Abonnement non trouvé"));
|
||||
|
||||
// Mettre en statut PAST_DUE
|
||||
subscription.updateStatus(SubscriptionStatus.PAST_DUE);
|
||||
|
||||
// Démarrer la période de grâce pour la licence
|
||||
if (subscription.getLicense() != null) {
|
||||
subscription.getLicense().startGracePeriod(GRACE_PERIOD_DAYS);
|
||||
}
|
||||
|
||||
subscriptionRepository.save(subscription);
|
||||
|
||||
// TODO: Envoyer notification d'échec de paiement
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void handlePaymentSuccess(String stripeSubscriptionId, String stripeInvoiceId) {
|
||||
Subscription subscription = subscriptionRepository.findByStripeSubscriptionId(stripeSubscriptionId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Abonnement non trouvé"));
|
||||
|
||||
// Remettre en statut actif si nécessaire
|
||||
if (subscription.getStatus() == SubscriptionStatus.PAST_DUE) {
|
||||
subscription.updateStatus(SubscriptionStatus.ACTIVE);
|
||||
|
||||
// Réactiver la licence
|
||||
if (subscription.getLicense() != null) {
|
||||
subscription.getLicense().reactivate();
|
||||
}
|
||||
}
|
||||
|
||||
// Mettre à jour les dates de période
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
subscription.setCurrentPeriodStart(now);
|
||||
subscription.setCurrentPeriodEnd(now.plusDays(subscription.getBillingCycle().getMonths() * 30L));
|
||||
subscription.setNextBillingDate(subscription.getCurrentPeriodEnd());
|
||||
|
||||
subscriptionRepository.save(subscription);
|
||||
|
||||
// TODO: Envoyer notification de paiement réussi
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Subscription> getSubscriptionsRequiringAttention() {
|
||||
List<Subscription> pastDue = subscriptionRepository.findByStatus(SubscriptionStatus.PAST_DUE);
|
||||
List<Subscription> unpaid = subscriptionRepository.findByStatus(SubscriptionStatus.UNPAID);
|
||||
List<Subscription> incomplete = subscriptionRepository.findByStatus(SubscriptionStatus.INCOMPLETE);
|
||||
|
||||
pastDue.addAll(unpaid);
|
||||
pastDue.addAll(incomplete);
|
||||
|
||||
return pastDue;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void startGracePeriod(UUID subscriptionId) {
|
||||
Subscription subscription = subscriptionRepository.findById(subscriptionId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Abonnement non trouvé"));
|
||||
|
||||
subscription.updateStatus(SubscriptionStatus.PAST_DUE);
|
||||
|
||||
if (subscription.getLicense() != null) {
|
||||
subscription.getLicense().startGracePeriod(GRACE_PERIOD_DAYS);
|
||||
}
|
||||
|
||||
subscriptionRepository.save(subscription);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void suspendUnpaidSubscriptions() {
|
||||
// Récupérer les abonnements en période de grâce expirée
|
||||
List<Subscription> gracePeriodExpired = subscriptionRepository.findInGracePeriod().stream()
|
||||
.filter(subscription -> subscription.getLicense() != null
|
||||
&& subscription.getLicense().getDaysRemainingInGracePeriod() <= 0)
|
||||
.toList();
|
||||
|
||||
for (Subscription subscription : gracePeriodExpired) {
|
||||
subscription.updateStatus(SubscriptionStatus.UNPAID);
|
||||
|
||||
if (subscription.getLicense() != null) {
|
||||
subscription.getLicense().suspend();
|
||||
}
|
||||
|
||||
subscriptionRepository.save(subscription);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gère les transitions d'état des abonnements
|
||||
*/
|
||||
private void handleStatusTransition(Subscription subscription, SubscriptionStatus oldStatus, SubscriptionStatus newStatus) {
|
||||
// Transition vers ACTIVE
|
||||
if (newStatus == SubscriptionStatus.ACTIVE && oldStatus != SubscriptionStatus.ACTIVE) {
|
||||
if (subscription.getLicense() != null) {
|
||||
subscription.getLicense().reactivate();
|
||||
}
|
||||
}
|
||||
|
||||
// Transition vers CANCELED
|
||||
if (newStatus == SubscriptionStatus.CANCELED) {
|
||||
if (subscription.getLicense() != null) {
|
||||
subscription.getLicense().suspend();
|
||||
}
|
||||
}
|
||||
|
||||
// Transition vers PAST_DUE
|
||||
if (newStatus == SubscriptionStatus.PAST_DUE) {
|
||||
if (subscription.getLicense() != null) {
|
||||
subscription.getLicense().startGracePeriod(GRACE_PERIOD_DAYS);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -57,6 +57,18 @@
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- Stripe SDK -->
|
||||
<dependency>
|
||||
<groupId>com.stripe</groupId>
|
||||
<artifactId>stripe-java</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Thymeleaf for email templates -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-thymeleaf</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- JWT -->
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
|
||||
@ -0,0 +1,9 @@
|
||||
package com.dh7789dev.xpeditis.entity;
|
||||
|
||||
/**
|
||||
* Énumération des cycles de facturation
|
||||
*/
|
||||
public enum BillingCycleEntity {
|
||||
MONTHLY, // Mensuel
|
||||
YEARLY // Annuel
|
||||
}
|
||||
@ -0,0 +1,245 @@
|
||||
package com.dh7789dev.xpeditis.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.AccessLevel;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
import lombok.experimental.FieldDefaults;
|
||||
import lombok.experimental.FieldNameConstants;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Entité JPA pour les factures Stripe
|
||||
*/
|
||||
@Entity
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@FieldNameConstants
|
||||
@FieldDefaults(level = AccessLevel.PRIVATE)
|
||||
@Table(name = "invoices")
|
||||
@EntityListeners(org.springframework.data.jpa.domain.support.AuditingEntityListener.class)
|
||||
public class InvoiceEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.UUID)
|
||||
@Column(columnDefinition = "BINARY(16)")
|
||||
UUID id;
|
||||
|
||||
@Column(name = "stripe_invoice_id", unique = true, nullable = false)
|
||||
String stripeInvoiceId;
|
||||
|
||||
@Column(name = "invoice_number", unique = true, nullable = false, length = 50)
|
||||
String invoiceNumber;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "subscription_id", nullable = false, foreignKey = @ForeignKey(name = "fk_invoice_subscription"))
|
||||
SubscriptionEntity subscription;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "status", nullable = false, length = 50)
|
||||
InvoiceStatusEntity status;
|
||||
|
||||
@Column(name = "amount_due", nullable = false, precision = 10, scale = 2)
|
||||
BigDecimal amountDue;
|
||||
|
||||
@Column(name = "amount_paid", precision = 10, scale = 2)
|
||||
BigDecimal amountPaid = BigDecimal.ZERO;
|
||||
|
||||
@Column(name = "currency", nullable = false, length = 3)
|
||||
String currency = "EUR";
|
||||
|
||||
@Column(name = "billing_period_start", nullable = false)
|
||||
LocalDateTime billingPeriodStart;
|
||||
|
||||
@Column(name = "billing_period_end", nullable = false)
|
||||
LocalDateTime billingPeriodEnd;
|
||||
|
||||
@Column(name = "due_date")
|
||||
LocalDateTime dueDate;
|
||||
|
||||
@Column(name = "paid_at")
|
||||
LocalDateTime paidAt;
|
||||
|
||||
@Column(name = "invoice_pdf_url", columnDefinition = "TEXT")
|
||||
String invoicePdfUrl;
|
||||
|
||||
@Column(name = "hosted_invoice_url", columnDefinition = "TEXT")
|
||||
String hostedInvoiceUrl;
|
||||
|
||||
@Column(name = "attempt_count", nullable = false)
|
||||
Integer attemptCount = 0;
|
||||
|
||||
@OneToMany(mappedBy = "invoice", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
|
||||
List<InvoiceLineItemEntity> lineItems;
|
||||
|
||||
@Column(name = "created_at", updatable = false)
|
||||
LocalDateTime createdAt;
|
||||
|
||||
@org.springframework.data.annotation.CreatedDate
|
||||
@Column(name = "created_date", updatable = false, nullable = false)
|
||||
java.time.Instant createdDate;
|
||||
|
||||
@org.springframework.data.annotation.LastModifiedDate
|
||||
@Column(name = "modified_date", nullable = false)
|
||||
java.time.Instant modifiedDate;
|
||||
|
||||
@org.springframework.data.annotation.CreatedBy
|
||||
@Column(name = "created_by", updatable = false, nullable = false)
|
||||
String createdBy = "SYSTEM";
|
||||
|
||||
@org.springframework.data.annotation.LastModifiedBy
|
||||
@Column(name = "modified_by")
|
||||
String modifiedBy;
|
||||
|
||||
@PrePersist
|
||||
public void onCreate() {
|
||||
if (id == null) {
|
||||
id = UUID.randomUUID();
|
||||
}
|
||||
if (createdAt == null) {
|
||||
createdAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true si la facture est payée
|
||||
*/
|
||||
public boolean isPaid() {
|
||||
return status == InvoiceStatusEntity.PAID;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true si la facture est en attente de paiement
|
||||
*/
|
||||
public boolean isPending() {
|
||||
return status == InvoiceStatusEntity.OPEN;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true si la facture est en retard
|
||||
*/
|
||||
public boolean isOverdue() {
|
||||
return dueDate != null && LocalDateTime.now().isAfter(dueDate) && !isPaid();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return le nombre de jours de retard (0 si pas en retard)
|
||||
*/
|
||||
public long getDaysOverdue() {
|
||||
if (!isOverdue()) return 0;
|
||||
|
||||
return java.time.temporal.ChronoUnit.DAYS.between(dueDate, LocalDateTime.now());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return le montant restant à payer
|
||||
*/
|
||||
public BigDecimal getRemainingAmount() {
|
||||
if (amountPaid == null) return amountDue;
|
||||
return amountDue.subtract(amountPaid);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true si la facture est partiellement payée
|
||||
*/
|
||||
public boolean isPartiallyPaid() {
|
||||
return amountPaid != null &&
|
||||
amountPaid.compareTo(BigDecimal.ZERO) > 0 &&
|
||||
amountPaid.compareTo(amountDue) < 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return le pourcentage de paiement effectué
|
||||
*/
|
||||
public double getPaymentPercentage() {
|
||||
if (amountDue.compareTo(BigDecimal.ZERO) == 0) return 100.0;
|
||||
if (amountPaid == null) return 0.0;
|
||||
|
||||
return amountPaid.divide(amountDue, 4, java.math.RoundingMode.HALF_UP)
|
||||
.multiply(BigDecimal.valueOf(100))
|
||||
.doubleValue();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return la durée de la période de facturation en jours
|
||||
*/
|
||||
public long getBillingPeriodDays() {
|
||||
return java.time.temporal.ChronoUnit.DAYS.between(billingPeriodStart, billingPeriodEnd);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true si cette facture nécessite une attention (retard, échecs répétés)
|
||||
*/
|
||||
public boolean requiresAttention() {
|
||||
return isOverdue() ||
|
||||
(attemptCount != null && attemptCount > 3) ||
|
||||
status == InvoiceStatusEntity.PAYMENT_FAILED;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return le montant total des lignes de facture
|
||||
*/
|
||||
public BigDecimal calculateTotalFromLineItems() {
|
||||
if (lineItems == null || lineItems.isEmpty()) {
|
||||
return BigDecimal.ZERO;
|
||||
}
|
||||
|
||||
return lineItems.stream()
|
||||
.map(InvoiceLineItemEntity::getAmount)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true si la facture inclut du prorata
|
||||
*/
|
||||
public boolean hasProrationItems() {
|
||||
return lineItems != null &&
|
||||
lineItems.stream().anyMatch(InvoiceLineItemEntity::getProrated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment le nombre de tentatives de paiement
|
||||
*/
|
||||
public void incrementAttemptCount() {
|
||||
if (attemptCount == null) {
|
||||
attemptCount = 1;
|
||||
} else {
|
||||
attemptCount++;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Marque la facture comme payée
|
||||
*/
|
||||
public void markAsPaid(LocalDateTime paidDate) {
|
||||
this.status = InvoiceStatusEntity.PAID;
|
||||
this.paidAt = paidDate;
|
||||
this.amountPaid = this.amountDue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Marque la facture comme échouée
|
||||
*/
|
||||
public void markAsFailed() {
|
||||
this.status = InvoiceStatusEntity.PAYMENT_FAILED;
|
||||
incrementAttemptCount();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Énumération des statuts de facture
|
||||
*/
|
||||
enum InvoiceStatusEntity {
|
||||
DRAFT, // Brouillon
|
||||
OPEN, // En attente de paiement
|
||||
PAID, // Payée
|
||||
PAYMENT_FAILED, // Échec de paiement
|
||||
VOIDED, // Annulée
|
||||
UNCOLLECTIBLE // Irrécouvrable
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -0,0 +1,224 @@
|
||||
package com.dh7789dev.xpeditis.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.AccessLevel;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
import lombok.experimental.FieldDefaults;
|
||||
import lombok.experimental.FieldNameConstants;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Entité JPA pour les plans d'abonnement
|
||||
*/
|
||||
@Entity
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@FieldNameConstants
|
||||
@FieldDefaults(level = AccessLevel.PRIVATE)
|
||||
@Table(name = "plans")
|
||||
@EntityListeners(org.springframework.data.jpa.domain.support.AuditingEntityListener.class)
|
||||
public class PlanEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.UUID)
|
||||
@Column(columnDefinition = "BINARY(16)")
|
||||
UUID id;
|
||||
|
||||
@Column(name = "name", length = 100, nullable = false, unique = true)
|
||||
String name;
|
||||
|
||||
@Column(name = "type", length = 50, nullable = false, unique = true)
|
||||
String type;
|
||||
|
||||
@Column(name = "stripe_price_id_monthly")
|
||||
String stripePriceIdMonthly;
|
||||
|
||||
@Column(name = "stripe_price_id_yearly")
|
||||
String stripePriceIdYearly;
|
||||
|
||||
@Column(name = "monthly_price", precision = 10, scale = 2)
|
||||
BigDecimal monthlyPrice;
|
||||
|
||||
@Column(name = "yearly_price", precision = 10, scale = 2)
|
||||
BigDecimal yearlyPrice;
|
||||
|
||||
@Column(name = "max_users", nullable = false)
|
||||
Integer maxUsers;
|
||||
|
||||
@Convert(converter = FeaturesConverter.class)
|
||||
@Column(name = "features", columnDefinition = "JSON")
|
||||
Set<String> features;
|
||||
|
||||
@Column(name = "trial_duration_days", nullable = false)
|
||||
Integer trialDurationDays = 14;
|
||||
|
||||
@Column(name = "is_active", nullable = false)
|
||||
Boolean isActive = true;
|
||||
|
||||
@Column(name = "display_order")
|
||||
Integer displayOrder;
|
||||
|
||||
@Convert(converter = MetadataConverter.class)
|
||||
@Column(name = "metadata", columnDefinition = "JSON")
|
||||
java.util.Map<String, Object> metadata;
|
||||
|
||||
@Column(name = "created_at", updatable = false)
|
||||
LocalDateTime createdAt;
|
||||
|
||||
@Column(name = "updated_at")
|
||||
LocalDateTime updatedAt;
|
||||
|
||||
@org.springframework.data.annotation.CreatedDate
|
||||
@Column(name = "created_date", updatable = false, nullable = false)
|
||||
java.time.Instant createdDate;
|
||||
|
||||
@org.springframework.data.annotation.LastModifiedDate
|
||||
@Column(name = "modified_date", nullable = false)
|
||||
java.time.Instant modifiedDate;
|
||||
|
||||
@org.springframework.data.annotation.CreatedBy
|
||||
@Column(name = "created_by", updatable = false, nullable = false)
|
||||
String createdBy = "SYSTEM";
|
||||
|
||||
@org.springframework.data.annotation.LastModifiedBy
|
||||
@Column(name = "modified_by")
|
||||
String modifiedBy;
|
||||
|
||||
@PrePersist
|
||||
public void onCreate() {
|
||||
if (id == null) {
|
||||
id = UUID.randomUUID();
|
||||
}
|
||||
if (createdAt == null) {
|
||||
createdAt = LocalDateTime.now();
|
||||
}
|
||||
updatedAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
public void onUpdate() {
|
||||
updatedAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true si le plan est disponible pour la souscription
|
||||
*/
|
||||
public boolean isAvailableForSubscription() {
|
||||
return isActive != null && isActive;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return le prix selon le cycle de facturation
|
||||
*/
|
||||
public BigDecimal getPriceForBillingCycle(String billingCycle) {
|
||||
if ("YEARLY".equals(billingCycle)) {
|
||||
return yearlyPrice;
|
||||
}
|
||||
return monthlyPrice;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return l'ID Stripe selon le cycle de facturation
|
||||
*/
|
||||
public String getStripePriceIdForBillingCycle(String billingCycle) {
|
||||
if ("YEARLY".equals(billingCycle)) {
|
||||
return stripePriceIdYearly;
|
||||
}
|
||||
return stripePriceIdMonthly;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true si le plan support un nombre illimité d'utilisateurs
|
||||
*/
|
||||
public boolean hasUnlimitedUsers() {
|
||||
return maxUsers != null && maxUsers == -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true si le plan inclut la fonctionnalité spécifiée
|
||||
*/
|
||||
public boolean hasFeature(String feature) {
|
||||
return features != null && features.contains(feature);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return le nombre de fonctionnalités incluses dans le plan
|
||||
*/
|
||||
public int getFeatureCount() {
|
||||
return features != null ? features.size() : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converter pour sérialiser/désérialiser les fonctionnalités en JSON
|
||||
*/
|
||||
@Converter
|
||||
public static class FeaturesConverter implements AttributeConverter<Set<String>, String> {
|
||||
private final com.fasterxml.jackson.databind.ObjectMapper objectMapper = new com.fasterxml.jackson.databind.ObjectMapper();
|
||||
|
||||
@Override
|
||||
public String convertToDatabaseColumn(Set<String> features) {
|
||||
if (features == null || features.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return objectMapper.writeValueAsString(features);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Erreur lors de la sérialisation des features", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("unchecked")
|
||||
public Set<String> convertToEntityAttribute(String json) {
|
||||
if (json == null || json.trim().isEmpty()) {
|
||||
return java.util.Collections.emptySet();
|
||||
}
|
||||
try {
|
||||
return objectMapper.readValue(json, Set.class);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Erreur lors de la désérialisation des features", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converter pour sérialiser/désérialiser les métadonnées en JSON
|
||||
*/
|
||||
@Converter
|
||||
public static class MetadataConverter implements AttributeConverter<java.util.Map<String, Object>, String> {
|
||||
private final com.fasterxml.jackson.databind.ObjectMapper objectMapper = new com.fasterxml.jackson.databind.ObjectMapper();
|
||||
|
||||
@Override
|
||||
public String convertToDatabaseColumn(java.util.Map<String, Object> metadata) {
|
||||
if (metadata == null || metadata.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return objectMapper.writeValueAsString(metadata);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Erreur lors de la sérialisation des metadata", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("unchecked")
|
||||
public java.util.Map<String, Object> convertToEntityAttribute(String json) {
|
||||
if (json == null || json.trim().isEmpty()) {
|
||||
return java.util.Collections.emptyMap();
|
||||
}
|
||||
try {
|
||||
return objectMapper.readValue(json, java.util.Map.class);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Erreur lors de la désérialisation des metadata", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,167 @@
|
||||
package com.dh7789dev.xpeditis.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.AccessLevel;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
import lombok.experimental.FieldDefaults;
|
||||
import lombok.experimental.FieldNameConstants;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Entité JPA pour les abonnements Stripe
|
||||
*/
|
||||
@Entity
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@FieldNameConstants
|
||||
@FieldDefaults(level = AccessLevel.PRIVATE)
|
||||
@Table(name = "subscriptions")
|
||||
@EntityListeners(org.springframework.data.jpa.domain.support.AuditingEntityListener.class)
|
||||
public class SubscriptionEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.UUID)
|
||||
@Column(columnDefinition = "BINARY(16)")
|
||||
UUID id;
|
||||
|
||||
@Column(name = "stripe_subscription_id", unique = true, nullable = false)
|
||||
String stripeSubscriptionId;
|
||||
|
||||
@Column(name = "stripe_customer_id", nullable = false)
|
||||
String stripeCustomerId;
|
||||
|
||||
@Column(name = "stripe_price_id", nullable = false)
|
||||
String stripePriceId;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "status", nullable = false)
|
||||
SubscriptionStatusEntity status;
|
||||
|
||||
@Column(name = "current_period_start", nullable = false)
|
||||
LocalDateTime currentPeriodStart;
|
||||
|
||||
@Column(name = "current_period_end", nullable = false)
|
||||
LocalDateTime currentPeriodEnd;
|
||||
|
||||
@Column(name = "cancel_at_period_end", nullable = false)
|
||||
boolean cancelAtPeriodEnd = false;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "billing_cycle", nullable = false)
|
||||
BillingCycleEntity billingCycle;
|
||||
|
||||
@Column(name = "next_billing_date")
|
||||
LocalDateTime nextBillingDate;
|
||||
|
||||
@Column(name = "trial_end_date")
|
||||
LocalDateTime trialEndDate;
|
||||
|
||||
@OneToOne(mappedBy = "subscription", cascade = CascadeType.ALL)
|
||||
LicenseEntity license;
|
||||
|
||||
@OneToMany(mappedBy = "subscription", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
|
||||
List<InvoiceEntity> invoices;
|
||||
|
||||
@Column(name = "created_at", updatable = false)
|
||||
LocalDateTime createdAt;
|
||||
|
||||
@Column(name = "updated_at")
|
||||
LocalDateTime updatedAt;
|
||||
|
||||
@org.springframework.data.annotation.CreatedDate
|
||||
@Column(name = "created_date", updatable = false, nullable = false)
|
||||
java.time.Instant createdDate;
|
||||
|
||||
@org.springframework.data.annotation.LastModifiedDate
|
||||
@Column(name = "modified_date", nullable = false)
|
||||
java.time.Instant modifiedDate;
|
||||
|
||||
@org.springframework.data.annotation.CreatedBy
|
||||
@Column(name = "created_by", updatable = false, nullable = false)
|
||||
String createdBy = "SYSTEM";
|
||||
|
||||
@org.springframework.data.annotation.LastModifiedBy
|
||||
@Column(name = "modified_by")
|
||||
String modifiedBy;
|
||||
|
||||
@PrePersist
|
||||
public void onCreate() {
|
||||
if (id == null) {
|
||||
id = UUID.randomUUID();
|
||||
}
|
||||
if (createdAt == null) {
|
||||
createdAt = LocalDateTime.now();
|
||||
}
|
||||
updatedAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
public void onUpdate() {
|
||||
updatedAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true si l'abonnement est actif
|
||||
*/
|
||||
public boolean isActive() {
|
||||
return status == SubscriptionStatusEntity.ACTIVE;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true si l'abonnement est en période d'essai
|
||||
*/
|
||||
public boolean isTrialing() {
|
||||
return status == SubscriptionStatusEntity.TRIALING;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true si l'abonnement est en retard de paiement
|
||||
*/
|
||||
public boolean isPastDue() {
|
||||
return status == SubscriptionStatusEntity.PAST_DUE;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true si l'abonnement est annulé
|
||||
*/
|
||||
public boolean isCanceled() {
|
||||
return status == SubscriptionStatusEntity.CANCELED;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true si l'abonnement peut être réactivé
|
||||
*/
|
||||
public boolean canBeReactivated() {
|
||||
return status == SubscriptionStatusEntity.CANCELED
|
||||
|| status == SubscriptionStatusEntity.PAST_DUE
|
||||
|| status == SubscriptionStatusEntity.UNPAID;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true si l'abonnement nécessite une attention
|
||||
*/
|
||||
public boolean requiresAttention() {
|
||||
return status == SubscriptionStatusEntity.PAST_DUE
|
||||
|| status == SubscriptionStatusEntity.UNPAID
|
||||
|| status == SubscriptionStatusEntity.INCOMPLETE
|
||||
|| status == SubscriptionStatusEntity.INCOMPLETE_EXPIRED;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return le nombre de jours jusqu'à la prochaine facturation
|
||||
*/
|
||||
public long getDaysUntilNextBilling() {
|
||||
if (nextBillingDate == null) return Long.MAX_VALUE;
|
||||
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
if (now.isAfter(nextBillingDate)) return 0;
|
||||
|
||||
return java.time.temporal.ChronoUnit.DAYS.between(now, nextBillingDate);
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -0,0 +1,134 @@
|
||||
package com.dh7789dev.xpeditis.mapper;
|
||||
|
||||
import com.dh7789dev.xpeditis.dto.app.InvoiceLineItem;
|
||||
import com.dh7789dev.xpeditis.entity.InvoiceLineItemEntity;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Mapper entre InvoiceLineItem (domaine) et InvoiceLineItemEntity (JPA)
|
||||
*/
|
||||
@Component
|
||||
public class InvoiceLineItemMapper {
|
||||
|
||||
/**
|
||||
* Convertit une entité JPA en objet domaine
|
||||
*/
|
||||
public InvoiceLineItem toDomain(InvoiceLineItemEntity entity) {
|
||||
if (entity == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
InvoiceLineItem lineItem = new InvoiceLineItem();
|
||||
lineItem.setId(entity.getId());
|
||||
lineItem.setDescription(entity.getDescription());
|
||||
lineItem.setQuantity(entity.getQuantity());
|
||||
lineItem.setUnitPrice(entity.getUnitPrice());
|
||||
lineItem.setAmount(entity.getAmount());
|
||||
lineItem.setStripePriceId(entity.getStripePriceId());
|
||||
lineItem.setPeriodStart(entity.getPeriodStart());
|
||||
lineItem.setPeriodEnd(entity.getPeriodEnd());
|
||||
lineItem.setProrated(entity.getProrated());
|
||||
lineItem.setCreatedAt(entity.getCreatedAt());
|
||||
|
||||
// L'ID de la facture sera défini par le mapper parent
|
||||
if (entity.getInvoice() != null) {
|
||||
lineItem.setInvoiceId(entity.getInvoice().getId());
|
||||
}
|
||||
|
||||
return lineItem;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertit un objet domaine en entité JPA
|
||||
*/
|
||||
public InvoiceLineItemEntity toEntity(InvoiceLineItem domain) {
|
||||
if (domain == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
InvoiceLineItemEntity entity = new InvoiceLineItemEntity();
|
||||
entity.setId(domain.getId());
|
||||
entity.setDescription(domain.getDescription());
|
||||
entity.setQuantity(domain.getQuantity());
|
||||
entity.setUnitPrice(domain.getUnitPrice());
|
||||
entity.setAmount(domain.getAmount());
|
||||
entity.setStripePriceId(domain.getStripePriceId());
|
||||
entity.setPeriodStart(domain.getPeriodStart());
|
||||
entity.setPeriodEnd(domain.getPeriodEnd());
|
||||
entity.setProrated(domain.getProrated());
|
||||
entity.setCreatedAt(domain.getCreatedAt());
|
||||
|
||||
// La relation avec la facture sera définie séparément
|
||||
|
||||
return entity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Met à jour une entité existante avec les données du domaine
|
||||
*/
|
||||
public void updateEntity(InvoiceLineItemEntity entity, InvoiceLineItem domain) {
|
||||
if (entity == null || domain == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
entity.setDescription(domain.getDescription());
|
||||
entity.setQuantity(domain.getQuantity());
|
||||
entity.setUnitPrice(domain.getUnitPrice());
|
||||
entity.setAmount(domain.getAmount());
|
||||
entity.setStripePriceId(domain.getStripePriceId());
|
||||
entity.setPeriodStart(domain.getPeriodStart());
|
||||
entity.setPeriodEnd(domain.getPeriodEnd());
|
||||
entity.setProrated(domain.getProrated());
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertit une liste d'entités en liste d'objets domaine
|
||||
*/
|
||||
public List<InvoiceLineItem> toDomainList(List<InvoiceLineItemEntity> entities) {
|
||||
if (entities == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return entities.stream()
|
||||
.map(this::toDomain)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertit une liste d'objets domaine en liste d'entités
|
||||
*/
|
||||
public List<InvoiceLineItemEntity> toEntityList(List<InvoiceLineItem> domains) {
|
||||
if (domains == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return domains.stream()
|
||||
.map(this::toEntity)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée une ligne de facture minimale (sans dates de période)
|
||||
*/
|
||||
public InvoiceLineItem toDomainMinimal(InvoiceLineItemEntity entity) {
|
||||
if (entity == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
InvoiceLineItem lineItem = new InvoiceLineItem();
|
||||
lineItem.setId(entity.getId());
|
||||
lineItem.setDescription(entity.getDescription());
|
||||
lineItem.setQuantity(entity.getQuantity());
|
||||
lineItem.setAmount(entity.getAmount());
|
||||
lineItem.setProrated(entity.getProrated());
|
||||
|
||||
if (entity.getInvoice() != null) {
|
||||
lineItem.setInvoiceId(entity.getInvoice().getId());
|
||||
}
|
||||
|
||||
return lineItem;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,239 @@
|
||||
package com.dh7789dev.xpeditis.mapper;
|
||||
|
||||
import com.dh7789dev.xpeditis.dto.app.Invoice;
|
||||
import com.dh7789dev.xpeditis.dto.app.InvoiceLineItem;
|
||||
import com.dh7789dev.xpeditis.dto.app.InvoiceStatus;
|
||||
import com.dh7789dev.xpeditis.entity.InvoiceEntity;
|
||||
import com.dh7789dev.xpeditis.entity.InvoiceStatusEntity;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Mapper entre Invoice (domaine) et InvoiceEntity (JPA)
|
||||
*/
|
||||
@Component
|
||||
public class InvoiceMapper {
|
||||
|
||||
private final SubscriptionMapper subscriptionMapper;
|
||||
private final InvoiceLineItemMapper lineItemMapper;
|
||||
|
||||
@Autowired
|
||||
public InvoiceMapper(SubscriptionMapper subscriptionMapper, InvoiceLineItemMapper lineItemMapper) {
|
||||
this.subscriptionMapper = subscriptionMapper;
|
||||
this.lineItemMapper = lineItemMapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertit une entité JPA en objet domaine
|
||||
*/
|
||||
public Invoice toDomain(InvoiceEntity entity) {
|
||||
if (entity == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Invoice invoice = new Invoice();
|
||||
invoice.setId(entity.getId());
|
||||
invoice.setStripeInvoiceId(entity.getStripeInvoiceId());
|
||||
invoice.setInvoiceNumber(entity.getInvoiceNumber());
|
||||
invoice.setStatus(mapStatusToDomain(entity.getStatus()));
|
||||
invoice.setAmountDue(entity.getAmountDue());
|
||||
invoice.setAmountPaid(entity.getAmountPaid());
|
||||
invoice.setCurrency(entity.getCurrency());
|
||||
invoice.setBillingPeriodStart(entity.getBillingPeriodStart());
|
||||
invoice.setBillingPeriodEnd(entity.getBillingPeriodEnd());
|
||||
invoice.setDueDate(entity.getDueDate());
|
||||
invoice.setPaidAt(entity.getPaidAt());
|
||||
invoice.setInvoicePdfUrl(entity.getInvoicePdfUrl());
|
||||
invoice.setHostedInvoiceUrl(entity.getHostedInvoiceUrl());
|
||||
invoice.setAttemptCount(entity.getAttemptCount());
|
||||
invoice.setCreatedAt(entity.getCreatedAt());
|
||||
|
||||
// Mapping des relations (attention aux cycles)
|
||||
if (entity.getSubscription() != null) {
|
||||
// On ne mappe pas la subscription complète pour éviter les cycles infinis
|
||||
// Elle sera chargée séparément si nécessaire
|
||||
invoice.setSubscriptionId(entity.getSubscription().getId());
|
||||
}
|
||||
|
||||
// Mapping des lignes de facture
|
||||
if (entity.getLineItems() != null && !entity.getLineItems().isEmpty()) {
|
||||
List<InvoiceLineItem> lineItems = entity.getLineItems().stream()
|
||||
.map(lineItemEntity -> {
|
||||
InvoiceLineItem lineItem = lineItemMapper.toDomain(lineItemEntity);
|
||||
// Éviter le cycle en définissant la référence à la facture
|
||||
lineItem.setInvoiceId(invoice.getId());
|
||||
return lineItem;
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
invoice.setLineItems(lineItems);
|
||||
}
|
||||
|
||||
return invoice;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertit un objet domaine en entité JPA
|
||||
*/
|
||||
public InvoiceEntity toEntity(Invoice domain) {
|
||||
if (domain == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
InvoiceEntity entity = new InvoiceEntity();
|
||||
entity.setId(domain.getId());
|
||||
entity.setStripeInvoiceId(domain.getStripeInvoiceId());
|
||||
entity.setInvoiceNumber(domain.getInvoiceNumber());
|
||||
entity.setStatus(mapStatusToEntity(domain.getStatus()));
|
||||
entity.setAmountDue(domain.getAmountDue());
|
||||
entity.setAmountPaid(domain.getAmountPaid());
|
||||
entity.setCurrency(domain.getCurrency());
|
||||
entity.setBillingPeriodStart(domain.getBillingPeriodStart());
|
||||
entity.setBillingPeriodEnd(domain.getBillingPeriodEnd());
|
||||
entity.setDueDate(domain.getDueDate());
|
||||
entity.setPaidAt(domain.getPaidAt());
|
||||
entity.setInvoicePdfUrl(domain.getInvoicePdfUrl());
|
||||
entity.setHostedInvoiceUrl(domain.getHostedInvoiceUrl());
|
||||
entity.setAttemptCount(domain.getAttemptCount());
|
||||
entity.setCreatedAt(domain.getCreatedAt());
|
||||
|
||||
// Les relations seront définies séparément
|
||||
|
||||
return entity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Met à jour une entité existante avec les données du domaine
|
||||
*/
|
||||
public void updateEntity(InvoiceEntity entity, Invoice domain) {
|
||||
if (entity == null || domain == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
entity.setInvoiceNumber(domain.getInvoiceNumber());
|
||||
entity.setStatus(mapStatusToEntity(domain.getStatus()));
|
||||
entity.setAmountDue(domain.getAmountDue());
|
||||
entity.setAmountPaid(domain.getAmountPaid());
|
||||
entity.setCurrency(domain.getCurrency());
|
||||
entity.setBillingPeriodStart(domain.getBillingPeriodStart());
|
||||
entity.setBillingPeriodEnd(domain.getBillingPeriodEnd());
|
||||
entity.setDueDate(domain.getDueDate());
|
||||
entity.setPaidAt(domain.getPaidAt());
|
||||
entity.setInvoicePdfUrl(domain.getInvoicePdfUrl());
|
||||
entity.setHostedInvoiceUrl(domain.getHostedInvoiceUrl());
|
||||
entity.setAttemptCount(domain.getAttemptCount());
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertit une liste d'entités en liste d'objets domaine
|
||||
*/
|
||||
public List<Invoice> toDomainList(List<InvoiceEntity> entities) {
|
||||
if (entities == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return entities.stream()
|
||||
.map(this::toDomain)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertit une liste d'objets domaine en liste d'entités
|
||||
*/
|
||||
public List<InvoiceEntity> toEntityList(List<Invoice> domains) {
|
||||
if (domains == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return domains.stream()
|
||||
.map(this::toEntity)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée une facture domaine minimale (sans lignes de facture)
|
||||
*/
|
||||
public Invoice toDomainMinimal(InvoiceEntity entity) {
|
||||
if (entity == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Invoice invoice = new Invoice();
|
||||
invoice.setId(entity.getId());
|
||||
invoice.setStripeInvoiceId(entity.getStripeInvoiceId());
|
||||
invoice.setInvoiceNumber(entity.getInvoiceNumber());
|
||||
invoice.setStatus(mapStatusToDomain(entity.getStatus()));
|
||||
invoice.setAmountDue(entity.getAmountDue());
|
||||
invoice.setAmountPaid(entity.getAmountPaid());
|
||||
invoice.setCurrency(entity.getCurrency());
|
||||
invoice.setDueDate(entity.getDueDate());
|
||||
invoice.setPaidAt(entity.getPaidAt());
|
||||
invoice.setAttemptCount(entity.getAttemptCount());
|
||||
invoice.setCreatedAt(entity.getCreatedAt());
|
||||
|
||||
if (entity.getSubscription() != null) {
|
||||
invoice.setSubscriptionId(entity.getSubscription().getId());
|
||||
}
|
||||
|
||||
return invoice;
|
||||
}
|
||||
|
||||
/**
|
||||
* Met à jour seulement le statut de paiement
|
||||
*/
|
||||
public void updatePaymentStatus(InvoiceEntity entity, InvoiceStatus status, java.time.LocalDateTime paidAt, java.math.BigDecimal amountPaid) {
|
||||
if (entity == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
entity.setStatus(mapStatusToEntity(status));
|
||||
entity.setPaidAt(paidAt);
|
||||
entity.setAmountPaid(amountPaid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Incrémente le nombre de tentatives
|
||||
*/
|
||||
public void incrementAttemptCount(InvoiceEntity entity) {
|
||||
if (entity == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
Integer currentCount = entity.getAttemptCount();
|
||||
entity.setAttemptCount(currentCount != null ? currentCount + 1 : 1);
|
||||
}
|
||||
|
||||
// ===== MÉTHODES DE MAPPING DES ÉNUMÉRATIONS =====
|
||||
|
||||
private InvoiceStatus mapStatusToDomain(InvoiceStatusEntity status) {
|
||||
if (status == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return switch (status) {
|
||||
case DRAFT -> InvoiceStatus.DRAFT;
|
||||
case OPEN -> InvoiceStatus.OPEN;
|
||||
case PAID -> InvoiceStatus.PAID;
|
||||
case PAYMENT_FAILED -> InvoiceStatus.PAYMENT_FAILED;
|
||||
case VOIDED -> InvoiceStatus.VOIDED;
|
||||
case UNCOLLECTIBLE -> InvoiceStatus.UNCOLLECTIBLE;
|
||||
};
|
||||
}
|
||||
|
||||
private InvoiceStatusEntity mapStatusToEntity(InvoiceStatus status) {
|
||||
if (status == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return switch (status) {
|
||||
case DRAFT -> InvoiceStatusEntity.DRAFT;
|
||||
case OPEN -> InvoiceStatusEntity.OPEN;
|
||||
case PAID -> InvoiceStatusEntity.PAID;
|
||||
case PAYMENT_FAILED -> InvoiceStatusEntity.PAYMENT_FAILED;
|
||||
case VOIDED -> InvoiceStatusEntity.VOIDED;
|
||||
case UNCOLLECTIBLE -> InvoiceStatusEntity.UNCOLLECTIBLE;
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,156 @@
|
||||
package com.dh7789dev.xpeditis.mapper;
|
||||
|
||||
import com.dh7789dev.xpeditis.dto.app.EventStatus;
|
||||
import com.dh7789dev.xpeditis.dto.app.PaymentEvent;
|
||||
import com.dh7789dev.xpeditis.entity.PaymentEventEntity;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Mapper entre PaymentEvent (domaine) et PaymentEventEntity (JPA)
|
||||
*/
|
||||
@Component
|
||||
public class PaymentEventMapper {
|
||||
|
||||
private final SubscriptionMapper subscriptionMapper;
|
||||
|
||||
@Autowired
|
||||
public PaymentEventMapper(SubscriptionMapper subscriptionMapper) {
|
||||
this.subscriptionMapper = subscriptionMapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertit une entité JPA en objet domaine
|
||||
*/
|
||||
public PaymentEvent toDomain(PaymentEventEntity entity) {
|
||||
if (entity == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
PaymentEvent event = new PaymentEvent();
|
||||
event.setId(entity.getId());
|
||||
event.setStripeEventId(entity.getStripeEventId());
|
||||
event.setEventType(entity.getEventType());
|
||||
event.setStatus(entity.getStatus());
|
||||
event.setPayload(entity.getPayload());
|
||||
event.setProcessedAt(entity.getProcessedAt());
|
||||
event.setErrorMessage(entity.getErrorMessage());
|
||||
event.setRetryCount(entity.getRetryCount());
|
||||
event.setCreatedAt(entity.getCreatedAt());
|
||||
|
||||
// Mapping de la relation subscription (sans cycle infini)
|
||||
if (entity.getSubscription() != null) {
|
||||
event.setSubscriptionId(entity.getSubscription().getId());
|
||||
// On ne mappe pas la subscription complète ici pour éviter les cycles
|
||||
// Si nécessaire, elle sera chargée séparément
|
||||
}
|
||||
|
||||
return event;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertit un objet domaine en entité JPA
|
||||
*/
|
||||
public PaymentEventEntity toEntity(PaymentEvent domain) {
|
||||
if (domain == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
PaymentEventEntity entity = new PaymentEventEntity();
|
||||
entity.setId(domain.getId());
|
||||
entity.setStripeEventId(domain.getStripeEventId());
|
||||
entity.setEventType(domain.getEventType());
|
||||
entity.setStatus(domain.getStatus());
|
||||
entity.setPayload(domain.getPayload());
|
||||
entity.setProcessedAt(domain.getProcessedAt());
|
||||
entity.setErrorMessage(domain.getErrorMessage());
|
||||
entity.setRetryCount(domain.getRetryCount());
|
||||
entity.setCreatedAt(domain.getCreatedAt());
|
||||
|
||||
// La relation subscription sera définie séparément
|
||||
|
||||
return entity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Met à jour une entité existante avec les données du domaine
|
||||
*/
|
||||
public void updateEntity(PaymentEventEntity entity, PaymentEvent domain) {
|
||||
if (entity == null || domain == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
entity.setEventType(domain.getEventType());
|
||||
entity.setStatus(domain.getStatus());
|
||||
entity.setPayload(domain.getPayload());
|
||||
entity.setProcessedAt(domain.getProcessedAt());
|
||||
entity.setErrorMessage(domain.getErrorMessage());
|
||||
entity.setRetryCount(domain.getRetryCount());
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertit une liste d'entités en liste d'objets domaine
|
||||
*/
|
||||
public List<PaymentEvent> toDomainList(List<PaymentEventEntity> entities) {
|
||||
if (entities == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return entities.stream()
|
||||
.map(this::toDomain)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertit une liste d'objets domaine en liste d'entités
|
||||
*/
|
||||
public List<PaymentEventEntity> toEntityList(List<PaymentEvent> domains) {
|
||||
if (domains == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return domains.stream()
|
||||
.map(this::toEntity)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée un événement domaine minimal (pour les cas 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());
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,195 @@
|
||||
package com.dh7789dev.xpeditis.mapper;
|
||||
|
||||
import com.dh7789dev.xpeditis.dto.app.PaymentMethod;
|
||||
import com.dh7789dev.xpeditis.dto.app.PaymentMethodType;
|
||||
import com.dh7789dev.xpeditis.entity.PaymentMethodEntity;
|
||||
import com.dh7789dev.xpeditis.entity.PaymentMethodTypeEntity;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Mapper entre PaymentMethod (domaine) et PaymentMethodEntity (JPA)
|
||||
*/
|
||||
@Component
|
||||
public class PaymentMethodMapper {
|
||||
|
||||
/**
|
||||
* Convertit une entité JPA en objet domaine
|
||||
*/
|
||||
public PaymentMethod toDomain(PaymentMethodEntity entity) {
|
||||
if (entity == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
PaymentMethod paymentMethod = new PaymentMethod();
|
||||
paymentMethod.setId(entity.getId());
|
||||
paymentMethod.setStripePaymentMethodId(entity.getStripePaymentMethodId());
|
||||
paymentMethod.setType(mapTypeToDomain(entity.getType()));
|
||||
paymentMethod.setIsDefault(entity.getIsDefault());
|
||||
paymentMethod.setCardBrand(entity.getCardBrand());
|
||||
paymentMethod.setCardLast4(entity.getCardLast4());
|
||||
paymentMethod.setCardExpMonth(entity.getCardExpMonth());
|
||||
paymentMethod.setCardExpYear(entity.getCardExpYear());
|
||||
paymentMethod.setBankName(entity.getBankName());
|
||||
paymentMethod.setCompanyId(entity.getCompanyId());
|
||||
paymentMethod.setCreatedAt(entity.getCreatedAt());
|
||||
|
||||
return paymentMethod;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertit un objet domaine en entité JPA
|
||||
*/
|
||||
public PaymentMethodEntity toEntity(PaymentMethod domain) {
|
||||
if (domain == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
PaymentMethodEntity entity = new PaymentMethodEntity();
|
||||
entity.setId(domain.getId());
|
||||
entity.setStripePaymentMethodId(domain.getStripePaymentMethodId());
|
||||
entity.setType(mapTypeToEntity(domain.getType()));
|
||||
entity.setIsDefault(domain.getIsDefault());
|
||||
entity.setCardBrand(domain.getCardBrand());
|
||||
entity.setCardLast4(domain.getCardLast4());
|
||||
entity.setCardExpMonth(domain.getCardExpMonth());
|
||||
entity.setCardExpYear(domain.getCardExpYear());
|
||||
entity.setBankName(domain.getBankName());
|
||||
entity.setCompanyId(domain.getCompanyId());
|
||||
entity.setCreatedAt(domain.getCreatedAt());
|
||||
|
||||
return entity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Met à jour une entité existante avec les données du domaine
|
||||
*/
|
||||
public void updateEntity(PaymentMethodEntity entity, PaymentMethod domain) {
|
||||
if (entity == null || domain == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
entity.setType(mapTypeToEntity(domain.getType()));
|
||||
entity.setIsDefault(domain.getIsDefault());
|
||||
entity.setCardBrand(domain.getCardBrand());
|
||||
entity.setCardLast4(domain.getCardLast4());
|
||||
entity.setCardExpMonth(domain.getCardExpMonth());
|
||||
entity.setCardExpYear(domain.getCardExpYear());
|
||||
entity.setBankName(domain.getBankName());
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertit une liste d'entités en liste d'objets domaine
|
||||
*/
|
||||
public List<PaymentMethod> toDomainList(List<PaymentMethodEntity> entities) {
|
||||
if (entities == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return entities.stream()
|
||||
.map(this::toDomain)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertit une liste d'objets domaine en liste d'entités
|
||||
*/
|
||||
public List<PaymentMethodEntity> toEntityList(List<PaymentMethod> domains) {
|
||||
if (domains == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return domains.stream()
|
||||
.map(this::toEntity)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée une méthode de paiement minimale (sans détails sensibles)
|
||||
*/
|
||||
public PaymentMethod toDomainMinimal(PaymentMethodEntity entity) {
|
||||
if (entity == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
PaymentMethod paymentMethod = new PaymentMethod();
|
||||
paymentMethod.setId(entity.getId());
|
||||
paymentMethod.setType(mapTypeToDomain(entity.getType()));
|
||||
paymentMethod.setIsDefault(entity.getIsDefault());
|
||||
paymentMethod.setCardBrand(entity.getCardBrand());
|
||||
paymentMethod.setCardLast4(entity.getCardLast4());
|
||||
paymentMethod.setCreatedAt(entity.getCreatedAt());
|
||||
paymentMethod.setCompanyId(entity.getCompanyId());
|
||||
|
||||
// Pas d'informations sensibles comme les dates d'expiration détaillées
|
||||
|
||||
return paymentMethod;
|
||||
}
|
||||
|
||||
/**
|
||||
* Met à jour seulement le statut par défaut
|
||||
*/
|
||||
public void updateDefaultStatus(PaymentMethodEntity entity, boolean isDefault) {
|
||||
if (entity == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
entity.setIsDefault(isDefault);
|
||||
}
|
||||
|
||||
/**
|
||||
* Met à jour les informations de carte (suite à une mise à jour Stripe)
|
||||
*/
|
||||
public void updateCardInfo(PaymentMethodEntity entity, String cardBrand, String cardLast4, Integer expMonth, Integer expYear) {
|
||||
if (entity == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
entity.setCardBrand(cardBrand);
|
||||
entity.setCardLast4(cardLast4);
|
||||
entity.setCardExpMonth(expMonth);
|
||||
entity.setCardExpYear(expYear);
|
||||
}
|
||||
|
||||
// ===== MÉTHODES DE MAPPING DES ÉNUMÉRATIONS =====
|
||||
|
||||
private PaymentMethodType mapTypeToDomain(PaymentMethodTypeEntity type) {
|
||||
if (type == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return switch (type) {
|
||||
case CARD -> PaymentMethodType.CARD;
|
||||
case SEPA_DEBIT -> PaymentMethodType.SEPA_DEBIT;
|
||||
case BANCONTACT -> PaymentMethodType.BANCONTACT;
|
||||
case GIROPAY -> PaymentMethodType.GIROPAY;
|
||||
case IDEAL -> PaymentMethodType.IDEAL;
|
||||
case SOFORT -> PaymentMethodType.SOFORT;
|
||||
case P24 -> PaymentMethodType.P24;
|
||||
case EPS -> PaymentMethodType.EPS;
|
||||
case FPX -> PaymentMethodType.FPX;
|
||||
case BACS_DEBIT -> PaymentMethodType.BACS_DEBIT;
|
||||
};
|
||||
}
|
||||
|
||||
private PaymentMethodTypeEntity mapTypeToEntity(PaymentMethodType type) {
|
||||
if (type == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return switch (type) {
|
||||
case CARD -> PaymentMethodTypeEntity.CARD;
|
||||
case SEPA_DEBIT -> PaymentMethodTypeEntity.SEPA_DEBIT;
|
||||
case BANCONTACT -> PaymentMethodTypeEntity.BANCONTACT;
|
||||
case GIROPAY -> PaymentMethodTypeEntity.GIROPAY;
|
||||
case IDEAL -> PaymentMethodTypeEntity.IDEAL;
|
||||
case SOFORT -> PaymentMethodTypeEntity.SOFORT;
|
||||
case P24 -> PaymentMethodTypeEntity.P24;
|
||||
case EPS -> PaymentMethodTypeEntity.EPS;
|
||||
case FPX -> PaymentMethodTypeEntity.FPX;
|
||||
case BACS_DEBIT -> PaymentMethodTypeEntity.BACS_DEBIT;
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,211 @@
|
||||
package com.dh7789dev.xpeditis.mapper;
|
||||
|
||||
import com.dh7789dev.xpeditis.dto.app.Plan;
|
||||
import com.dh7789dev.xpeditis.entity.PlanEntity;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Mapper entre Plan (domaine) et PlanEntity (JPA)
|
||||
*/
|
||||
@Component
|
||||
public class PlanMapper {
|
||||
|
||||
/**
|
||||
* Convertit une entité JPA en objet domaine
|
||||
*/
|
||||
public Plan toDomain(PlanEntity entity) {
|
||||
if (entity == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Plan plan = new Plan();
|
||||
plan.setId(entity.getId());
|
||||
plan.setName(entity.getName());
|
||||
plan.setType(entity.getType());
|
||||
plan.setStripePriceIdMonthly(entity.getStripePriceIdMonthly());
|
||||
plan.setStripePriceIdYearly(entity.getStripePriceIdYearly());
|
||||
plan.setMonthlyPrice(entity.getMonthlyPrice());
|
||||
plan.setYearlyPrice(entity.getYearlyPrice());
|
||||
plan.setMaxUsers(entity.getMaxUsers());
|
||||
plan.setFeatures(entity.getFeatures());
|
||||
plan.setTrialDurationDays(entity.getTrialDurationDays());
|
||||
plan.setIsActive(entity.getIsActive());
|
||||
plan.setDisplayOrder(entity.getDisplayOrder());
|
||||
plan.setMetadata(entity.getMetadata());
|
||||
plan.setCreatedAt(entity.getCreatedAt());
|
||||
plan.setUpdatedAt(entity.getUpdatedAt());
|
||||
|
||||
return plan;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertit un objet domaine en entité JPA
|
||||
*/
|
||||
public PlanEntity toEntity(Plan domain) {
|
||||
if (domain == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
PlanEntity entity = new PlanEntity();
|
||||
entity.setId(domain.getId());
|
||||
entity.setName(domain.getName());
|
||||
entity.setType(domain.getType());
|
||||
entity.setStripePriceIdMonthly(domain.getStripePriceIdMonthly());
|
||||
entity.setStripePriceIdYearly(domain.getStripePriceIdYearly());
|
||||
entity.setMonthlyPrice(domain.getMonthlyPrice());
|
||||
entity.setYearlyPrice(domain.getYearlyPrice());
|
||||
entity.setMaxUsers(domain.getMaxUsers());
|
||||
entity.setFeatures(domain.getFeatures());
|
||||
entity.setTrialDurationDays(domain.getTrialDurationDays());
|
||||
entity.setIsActive(domain.getIsActive());
|
||||
entity.setDisplayOrder(domain.getDisplayOrder());
|
||||
entity.setMetadata(domain.getMetadata());
|
||||
entity.setCreatedAt(domain.getCreatedAt());
|
||||
entity.setUpdatedAt(domain.getUpdatedAt());
|
||||
|
||||
return entity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Met à jour une entité existante avec les données du domaine
|
||||
*/
|
||||
public void updateEntity(PlanEntity entity, Plan domain) {
|
||||
if (entity == null || domain == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
entity.setName(domain.getName());
|
||||
entity.setType(domain.getType());
|
||||
entity.setStripePriceIdMonthly(domain.getStripePriceIdMonthly());
|
||||
entity.setStripePriceIdYearly(domain.getStripePriceIdYearly());
|
||||
entity.setMonthlyPrice(domain.getMonthlyPrice());
|
||||
entity.setYearlyPrice(domain.getYearlyPrice());
|
||||
entity.setMaxUsers(domain.getMaxUsers());
|
||||
entity.setFeatures(domain.getFeatures());
|
||||
entity.setTrialDurationDays(domain.getTrialDurationDays());
|
||||
entity.setIsActive(domain.getIsActive());
|
||||
entity.setDisplayOrder(domain.getDisplayOrder());
|
||||
entity.setMetadata(domain.getMetadata());
|
||||
entity.setUpdatedAt(domain.getUpdatedAt());
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertit une liste d'entités en liste d'objets domaine
|
||||
*/
|
||||
public List<Plan> toDomainList(List<PlanEntity> entities) {
|
||||
if (entities == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return entities.stream()
|
||||
.map(this::toDomain)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertit une liste d'objets domaine en liste d'entités
|
||||
*/
|
||||
public List<PlanEntity> toEntityList(List<Plan> domains) {
|
||||
if (domains == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return domains.stream()
|
||||
.map(this::toEntity)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée un plan domaine minimal (pour les listes et aperçus)
|
||||
*/
|
||||
public Plan toDomainMinimal(PlanEntity entity) {
|
||||
if (entity == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Plan plan = new Plan();
|
||||
plan.setId(entity.getId());
|
||||
plan.setName(entity.getName());
|
||||
plan.setType(entity.getType());
|
||||
plan.setMonthlyPrice(entity.getMonthlyPrice());
|
||||
plan.setYearlyPrice(entity.getYearlyPrice());
|
||||
plan.setMaxUsers(entity.getMaxUsers());
|
||||
plan.setIsActive(entity.getIsActive());
|
||||
plan.setDisplayOrder(entity.getDisplayOrder());
|
||||
|
||||
// Features simplifiées (juste le count)
|
||||
if (entity.getFeatures() != null) {
|
||||
plan.setFeatures(entity.getFeatures());
|
||||
}
|
||||
|
||||
return plan;
|
||||
}
|
||||
|
||||
/**
|
||||
* Met à jour seulement les prix et IDs Stripe (pour les synchronisations)
|
||||
*/
|
||||
public void updatePricingInfo(PlanEntity entity, Plan domain) {
|
||||
if (entity == null || domain == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
entity.setStripePriceIdMonthly(domain.getStripePriceIdMonthly());
|
||||
entity.setStripePriceIdYearly(domain.getStripePriceIdYearly());
|
||||
entity.setMonthlyPrice(domain.getMonthlyPrice());
|
||||
entity.setYearlyPrice(domain.getYearlyPrice());
|
||||
entity.setUpdatedAt(domain.getUpdatedAt());
|
||||
}
|
||||
|
||||
/**
|
||||
* Met à jour seulement les fonctionnalités
|
||||
*/
|
||||
public void updateFeatures(PlanEntity entity, Set<String> features) {
|
||||
if (entity == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
entity.setFeatures(features);
|
||||
entity.setUpdatedAt(java.time.LocalDateTime.now());
|
||||
}
|
||||
|
||||
/**
|
||||
* Met à jour seulement le statut actif
|
||||
*/
|
||||
public void updateActiveStatus(PlanEntity entity, boolean isActive) {
|
||||
if (entity == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
entity.setIsActive(isActive);
|
||||
entity.setUpdatedAt(java.time.LocalDateTime.now());
|
||||
}
|
||||
|
||||
/**
|
||||
* Met à jour seulement l'ordre d'affichage
|
||||
*/
|
||||
public void updateDisplayOrder(PlanEntity entity, int displayOrder) {
|
||||
if (entity == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
entity.setDisplayOrder(displayOrder);
|
||||
entity.setUpdatedAt(java.time.LocalDateTime.now());
|
||||
}
|
||||
|
||||
/**
|
||||
* Met à jour les métadonnées
|
||||
*/
|
||||
public void updateMetadata(PlanEntity entity, Map<String, Object> metadata) {
|
||||
if (entity == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
entity.setMetadata(metadata);
|
||||
entity.setUpdatedAt(java.time.LocalDateTime.now());
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,201 @@
|
||||
package com.dh7789dev.xpeditis.mapper;
|
||||
|
||||
import com.dh7789dev.xpeditis.dto.app.*;
|
||||
import com.dh7789dev.xpeditis.entity.SubscriptionEntity;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Mapper entre Subscription (domaine) et SubscriptionEntity (JPA)
|
||||
*/
|
||||
@Component
|
||||
public class SubscriptionMapper {
|
||||
|
||||
private final LicenseMapper licenseMapper;
|
||||
private final InvoiceMapper invoiceMapper;
|
||||
|
||||
@Autowired
|
||||
public SubscriptionMapper(LicenseMapper licenseMapper, InvoiceMapper invoiceMapper) {
|
||||
this.licenseMapper = licenseMapper;
|
||||
this.invoiceMapper = invoiceMapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertit une entité JPA en objet domaine
|
||||
*/
|
||||
public Subscription toDomain(SubscriptionEntity entity) {
|
||||
if (entity == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Subscription subscription = new Subscription();
|
||||
subscription.setId(entity.getId());
|
||||
subscription.setStripeSubscriptionId(entity.getStripeSubscriptionId());
|
||||
subscription.setStripeCustomerId(entity.getStripeCustomerId());
|
||||
subscription.setStripePriceId(entity.getStripePriceId());
|
||||
subscription.setStatus(mapStatusToDomain(entity.getStatus()));
|
||||
subscription.setCurrentPeriodStart(entity.getCurrentPeriodStart());
|
||||
subscription.setCurrentPeriodEnd(entity.getCurrentPeriodEnd());
|
||||
subscription.setCancelAtPeriodEnd(entity.getCancelAtPeriodEnd());
|
||||
subscription.setBillingCycle(mapBillingCycleToDomain(entity.getBillingCycle()));
|
||||
subscription.setNextBillingDate(entity.getNextBillingDate());
|
||||
subscription.setTrialEndDate(entity.getTrialEndDate());
|
||||
subscription.setCreatedAt(entity.getCreatedAt());
|
||||
subscription.setUpdatedAt(entity.getUpdatedAt());
|
||||
|
||||
// Mapping des relations (sans cycles infinis)
|
||||
if (entity.getLicense() != null) {
|
||||
subscription.setLicense(licenseMapper.toDomain(entity.getLicense()));
|
||||
}
|
||||
|
||||
if (entity.getInvoices() != null && !entity.getInvoices().isEmpty()) {
|
||||
List<Invoice> invoices = entity.getInvoices().stream()
|
||||
.map(invoiceEntity -> {
|
||||
Invoice invoice = invoiceMapper.toDomain(invoiceEntity);
|
||||
// Éviter le cycle infini en ne remappant pas la subscription
|
||||
invoice.setSubscription(subscription);
|
||||
return invoice;
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
subscription.setInvoices(invoices);
|
||||
}
|
||||
|
||||
return subscription;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertit un objet domaine en entité JPA
|
||||
*/
|
||||
public SubscriptionEntity toEntity(Subscription domain) {
|
||||
if (domain == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
SubscriptionEntity entity = new SubscriptionEntity();
|
||||
entity.setId(domain.getId());
|
||||
entity.setStripeSubscriptionId(domain.getStripeSubscriptionId());
|
||||
entity.setStripeCustomerId(domain.getStripeCustomerId());
|
||||
entity.setStripePriceId(domain.getStripePriceId());
|
||||
entity.setStatus(mapStatusToEntity(domain.getStatus()));
|
||||
entity.setCurrentPeriodStart(domain.getCurrentPeriodStart());
|
||||
entity.setCurrentPeriodEnd(domain.getCurrentPeriodEnd());
|
||||
entity.setCancelAtPeriodEnd(domain.getCancelAtPeriodEnd());
|
||||
entity.setBillingCycle(mapBillingCycleToEntity(domain.getBillingCycle()));
|
||||
entity.setNextBillingDate(domain.getNextBillingDate());
|
||||
entity.setTrialEndDate(domain.getTrialEndDate());
|
||||
entity.setCreatedAt(domain.getCreatedAt());
|
||||
entity.setUpdatedAt(domain.getUpdatedAt());
|
||||
|
||||
// Relations mappées séparément pour éviter les cycles
|
||||
|
||||
return entity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Met à jour une entité existante avec les données du domaine
|
||||
*/
|
||||
public void updateEntity(SubscriptionEntity entity, Subscription domain) {
|
||||
if (entity == null || domain == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
entity.setStripeSubscriptionId(domain.getStripeSubscriptionId());
|
||||
entity.setStripeCustomerId(domain.getStripeCustomerId());
|
||||
entity.setStripePriceId(domain.getStripePriceId());
|
||||
entity.setStatus(mapStatusToEntity(domain.getStatus()));
|
||||
entity.setCurrentPeriodStart(domain.getCurrentPeriodStart());
|
||||
entity.setCurrentPeriodEnd(domain.getCurrentPeriodEnd());
|
||||
entity.setCancelAtPeriodEnd(domain.getCancelAtPeriodEnd());
|
||||
entity.setBillingCycle(mapBillingCycleToEntity(domain.getBillingCycle()));
|
||||
entity.setNextBillingDate(domain.getNextBillingDate());
|
||||
entity.setTrialEndDate(domain.getTrialEndDate());
|
||||
entity.setUpdatedAt(domain.getUpdatedAt());
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertit une liste d'entités en liste d'objets domaine
|
||||
*/
|
||||
public List<Subscription> toDomainList(List<SubscriptionEntity> entities) {
|
||||
if (entities == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return entities.stream()
|
||||
.map(this::toDomain)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertit une liste d'objets domaine en liste d'entités
|
||||
*/
|
||||
public List<SubscriptionEntity> toEntityList(List<Subscription> domains) {
|
||||
if (domains == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return domains.stream()
|
||||
.map(this::toEntity)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
// ===== MÉTHODES DE MAPPING DES ÉNUMÉRATIONS =====
|
||||
|
||||
private SubscriptionStatus mapStatusToDomain(com.dh7789dev.xpeditis.entity.SubscriptionStatusEntity status) {
|
||||
if (status == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return switch (status) {
|
||||
case INCOMPLETE -> SubscriptionStatus.INCOMPLETE;
|
||||
case INCOMPLETE_EXPIRED -> SubscriptionStatus.INCOMPLETE_EXPIRED;
|
||||
case TRIALING -> SubscriptionStatus.TRIALING;
|
||||
case ACTIVE -> SubscriptionStatus.ACTIVE;
|
||||
case PAST_DUE -> SubscriptionStatus.PAST_DUE;
|
||||
case CANCELED -> SubscriptionStatus.CANCELED;
|
||||
case UNPAID -> SubscriptionStatus.UNPAID;
|
||||
case PAUSED -> SubscriptionStatus.PAUSED;
|
||||
};
|
||||
}
|
||||
|
||||
private com.dh7789dev.xpeditis.entity.SubscriptionStatusEntity mapStatusToEntity(SubscriptionStatus status) {
|
||||
if (status == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return switch (status) {
|
||||
case INCOMPLETE -> com.dh7789dev.xpeditis.entity.SubscriptionStatusEntity.INCOMPLETE;
|
||||
case INCOMPLETE_EXPIRED -> com.dh7789dev.xpeditis.entity.SubscriptionStatusEntity.INCOMPLETE_EXPIRED;
|
||||
case TRIALING -> com.dh7789dev.xpeditis.entity.SubscriptionStatusEntity.TRIALING;
|
||||
case ACTIVE -> com.dh7789dev.xpeditis.entity.SubscriptionStatusEntity.ACTIVE;
|
||||
case PAST_DUE -> com.dh7789dev.xpeditis.entity.SubscriptionStatusEntity.PAST_DUE;
|
||||
case CANCELED -> com.dh7789dev.xpeditis.entity.SubscriptionStatusEntity.CANCELED;
|
||||
case UNPAID -> com.dh7789dev.xpeditis.entity.SubscriptionStatusEntity.UNPAID;
|
||||
case PAUSED -> com.dh7789dev.xpeditis.entity.SubscriptionStatusEntity.PAUSED;
|
||||
};
|
||||
}
|
||||
|
||||
private BillingCycle mapBillingCycleToDomain(com.dh7789dev.xpeditis.entity.BillingCycleEntity billingCycle) {
|
||||
if (billingCycle == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return switch (billingCycle) {
|
||||
case MONTHLY -> BillingCycle.MONTHLY;
|
||||
case YEARLY -> BillingCycle.YEARLY;
|
||||
};
|
||||
}
|
||||
|
||||
private com.dh7789dev.xpeditis.entity.BillingCycleEntity mapBillingCycleToEntity(BillingCycle billingCycle) {
|
||||
if (billingCycle == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return switch (billingCycle) {
|
||||
case MONTHLY -> com.dh7789dev.xpeditis.entity.BillingCycleEntity.MONTHLY;
|
||||
case YEARLY -> com.dh7789dev.xpeditis.entity.BillingCycleEntity.YEARLY;
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,332 @@
|
||||
package com.dh7789dev.xpeditis.repository;
|
||||
|
||||
import com.dh7789dev.xpeditis.entity.InvoiceEntity;
|
||||
import com.dh7789dev.xpeditis.entity.InvoiceStatusEntity;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Repository JPA pour les factures
|
||||
*/
|
||||
@Repository
|
||||
public interface InvoiceJpaRepository extends JpaRepository<InvoiceEntity, UUID> {
|
||||
|
||||
// ===== RECHERCHES DE BASE =====
|
||||
|
||||
/**
|
||||
* Trouve une facture par son ID Stripe
|
||||
*/
|
||||
Optional<InvoiceEntity> findByStripeInvoiceId(String stripeInvoiceId);
|
||||
|
||||
/**
|
||||
* Trouve une facture par son numéro
|
||||
*/
|
||||
Optional<InvoiceEntity> findByInvoiceNumber(String invoiceNumber);
|
||||
|
||||
/**
|
||||
* Trouve les factures d'un abonnement
|
||||
*/
|
||||
List<InvoiceEntity> findBySubscriptionIdOrderByCreatedAtDesc(UUID subscriptionId);
|
||||
|
||||
/**
|
||||
* Trouve les factures d'un abonnement avec pagination
|
||||
*/
|
||||
Page<InvoiceEntity> findBySubscriptionId(UUID subscriptionId, Pageable pageable);
|
||||
|
||||
/**
|
||||
* Vérifie l'existence d'une facture par ID Stripe
|
||||
*/
|
||||
boolean existsByStripeInvoiceId(String stripeInvoiceId);
|
||||
|
||||
// ===== RECHERCHES PAR STATUT =====
|
||||
|
||||
/**
|
||||
* Trouve les factures par statut
|
||||
*/
|
||||
List<InvoiceEntity> findByStatusOrderByCreatedAtDesc(InvoiceStatusEntity status);
|
||||
|
||||
/**
|
||||
* Trouve les factures par statut avec pagination
|
||||
*/
|
||||
Page<InvoiceEntity> findByStatus(InvoiceStatusEntity status, Pageable pageable);
|
||||
|
||||
/**
|
||||
* Trouve les factures ouvertes (en attente de paiement)
|
||||
*/
|
||||
@Query("SELECT i FROM InvoiceEntity i WHERE i.status = 'OPEN' ORDER BY i.dueDate ASC NULLS LAST")
|
||||
List<InvoiceEntity> findOpenInvoices();
|
||||
|
||||
/**
|
||||
* Trouve les factures payées
|
||||
*/
|
||||
@Query("SELECT i FROM InvoiceEntity i WHERE i.status = 'PAID' ORDER BY i.paidAt DESC")
|
||||
List<InvoiceEntity> findPaidInvoices();
|
||||
|
||||
/**
|
||||
* Trouve les factures échouées
|
||||
*/
|
||||
@Query("SELECT i FROM InvoiceEntity i WHERE i.status = 'PAYMENT_FAILED' ORDER BY i.createdAt DESC")
|
||||
List<InvoiceEntity> findFailedInvoices();
|
||||
|
||||
// ===== RECHERCHES PAR DATE =====
|
||||
|
||||
/**
|
||||
* Trouve les factures échues
|
||||
*/
|
||||
@Query("SELECT i FROM InvoiceEntity i WHERE i.dueDate < :now AND i.status = 'OPEN' ORDER BY i.dueDate ASC")
|
||||
List<InvoiceEntity> findOverdueInvoices(@Param("now") LocalDateTime now);
|
||||
|
||||
/**
|
||||
* Trouve les factures échues depuis plus de X jours
|
||||
*/
|
||||
@Query("SELECT i FROM InvoiceEntity i WHERE i.dueDate < :cutoffDate AND i.status = 'OPEN' ORDER BY i.dueDate ASC")
|
||||
List<InvoiceEntity> findInvoicesOverdueSince(@Param("cutoffDate") LocalDateTime cutoffDate);
|
||||
|
||||
/**
|
||||
* Trouve les factures avec échéance dans les X prochains jours
|
||||
*/
|
||||
@Query("SELECT i FROM InvoiceEntity i WHERE i.dueDate BETWEEN :now AND :endDate AND i.status = 'OPEN' ORDER BY i.dueDate ASC")
|
||||
List<InvoiceEntity> findInvoicesDueSoon(@Param("now") LocalDateTime now, @Param("endDate") LocalDateTime endDate);
|
||||
|
||||
/**
|
||||
* Trouve les factures créées dans une période
|
||||
*/
|
||||
List<InvoiceEntity> findByCreatedAtBetweenOrderByCreatedAtDesc(LocalDateTime startDate, LocalDateTime endDate);
|
||||
|
||||
/**
|
||||
* Trouve les factures payées dans une période
|
||||
*/
|
||||
@Query("SELECT i FROM InvoiceEntity i WHERE i.paidAt BETWEEN :startDate AND :endDate ORDER BY i.paidAt DESC")
|
||||
List<InvoiceEntity> findInvoicesPaidBetween(@Param("startDate") LocalDateTime startDate, @Param("endDate") LocalDateTime endDate);
|
||||
|
||||
// ===== RECHERCHES PAR PÉRIODE DE FACTURATION =====
|
||||
|
||||
/**
|
||||
* Trouve les factures pour une période de facturation spécifique
|
||||
*/
|
||||
@Query("SELECT i FROM InvoiceEntity i WHERE i.billingPeriodStart = :periodStart AND i.billingPeriodEnd = :periodEnd")
|
||||
List<InvoiceEntity> findByBillingPeriod(@Param("periodStart") LocalDateTime periodStart, @Param("periodEnd") LocalDateTime periodEnd);
|
||||
|
||||
/**
|
||||
* Trouve les factures qui chevauchent une période donnée
|
||||
*/
|
||||
@Query("""
|
||||
SELECT i FROM InvoiceEntity i
|
||||
WHERE i.billingPeriodStart < :endDate
|
||||
AND i.billingPeriodEnd > :startDate
|
||||
ORDER BY i.billingPeriodStart ASC
|
||||
""")
|
||||
List<InvoiceEntity> findInvoicesOverlappingPeriod(@Param("startDate") LocalDateTime startDate, @Param("endDate") LocalDateTime endDate);
|
||||
|
||||
// ===== RECHERCHES PAR MONTANT =====
|
||||
|
||||
/**
|
||||
* Trouve les factures par montant minimum
|
||||
*/
|
||||
List<InvoiceEntity> findByAmountDueGreaterThanEqualOrderByAmountDueDesc(BigDecimal minAmount);
|
||||
|
||||
/**
|
||||
* Trouve les factures dans une fourchette de montants
|
||||
*/
|
||||
List<InvoiceEntity> findByAmountDueBetweenOrderByAmountDueDesc(BigDecimal minAmount, BigDecimal maxAmount);
|
||||
|
||||
/**
|
||||
* Trouve les petites factures (moins de X euros)
|
||||
*/
|
||||
@Query("SELECT i FROM InvoiceEntity i WHERE i.amountDue < :maxAmount ORDER BY i.amountDue ASC")
|
||||
List<InvoiceEntity> findSmallInvoices(@Param("maxAmount") BigDecimal maxAmount);
|
||||
|
||||
/**
|
||||
* Trouve les grosses factures (plus de X euros)
|
||||
*/
|
||||
@Query("SELECT i FROM InvoiceEntity i WHERE i.amountDue > :minAmount ORDER BY i.amountDue DESC")
|
||||
List<InvoiceEntity> findLargeInvoices(@Param("minAmount") BigDecimal minAmount);
|
||||
|
||||
// ===== RECHERCHES PAR TENTATIVES DE PAIEMENT =====
|
||||
|
||||
/**
|
||||
* Trouve les factures avec plusieurs tentatives d'échec
|
||||
*/
|
||||
@Query("SELECT i FROM InvoiceEntity i WHERE i.attemptCount > :minAttempts ORDER BY i.attemptCount DESC")
|
||||
List<InvoiceEntity> findInvoicesWithMultipleAttempts(@Param("minAttempts") int minAttempts);
|
||||
|
||||
/**
|
||||
* Trouve les factures nécessitant une attention (échouées plusieurs fois)
|
||||
*/
|
||||
@Query("SELECT i FROM InvoiceEntity i WHERE i.attemptCount >= 3 AND i.status IN ('OPEN', 'PAYMENT_FAILED') ORDER BY i.attemptCount DESC")
|
||||
List<InvoiceEntity> findInvoicesRequiringAttention();
|
||||
|
||||
// ===== RECHERCHES PAR ABONNEMENT =====
|
||||
|
||||
/**
|
||||
* Trouve la dernière facture d'un abonnement
|
||||
*/
|
||||
@Query("SELECT i FROM InvoiceEntity i WHERE i.subscription.id = :subscriptionId ORDER BY i.createdAt DESC LIMIT 1")
|
||||
Optional<InvoiceEntity> findLatestInvoiceBySubscription(@Param("subscriptionId") UUID subscriptionId);
|
||||
|
||||
/**
|
||||
* Trouve la première facture d'un abonnement
|
||||
*/
|
||||
@Query("SELECT i FROM InvoiceEntity i WHERE i.subscription.id = :subscriptionId ORDER BY i.createdAt ASC LIMIT 1")
|
||||
Optional<InvoiceEntity> findFirstInvoiceBySubscription(@Param("subscriptionId") UUID subscriptionId);
|
||||
|
||||
/**
|
||||
* Compte les factures d'un abonnement
|
||||
*/
|
||||
long countBySubscriptionId(UUID subscriptionId);
|
||||
|
||||
/**
|
||||
* Trouve les factures impayées d'un abonnement
|
||||
*/
|
||||
@Query("SELECT i FROM InvoiceEntity i WHERE i.subscription.id = :subscriptionId AND i.status IN ('OPEN', 'PAYMENT_FAILED') ORDER BY i.dueDate ASC")
|
||||
List<InvoiceEntity> findUnpaidInvoicesBySubscription(@Param("subscriptionId") UUID subscriptionId);
|
||||
|
||||
// ===== STATISTIQUES ET MÉTRIQUES =====
|
||||
|
||||
/**
|
||||
* Compte les factures par statut
|
||||
*/
|
||||
long countByStatus(InvoiceStatusEntity status);
|
||||
|
||||
/**
|
||||
* Calcule le montant total facturé dans une période
|
||||
*/
|
||||
@Query("SELECT COALESCE(SUM(i.amountDue), 0) FROM InvoiceEntity i WHERE i.createdAt BETWEEN :startDate AND :endDate")
|
||||
BigDecimal calculateTotalInvoicedBetween(@Param("startDate") LocalDateTime startDate, @Param("endDate") LocalDateTime endDate);
|
||||
|
||||
/**
|
||||
* Calcule le montant total payé dans une période
|
||||
*/
|
||||
@Query("SELECT COALESCE(SUM(i.amountPaid), 0) FROM InvoiceEntity i WHERE i.paidAt BETWEEN :startDate AND :endDate")
|
||||
BigDecimal calculateTotalPaidBetween(@Param("startDate") LocalDateTime startDate, @Param("endDate") LocalDateTime endDate);
|
||||
|
||||
/**
|
||||
* Calcule le montant total en attente
|
||||
*/
|
||||
@Query("SELECT COALESCE(SUM(i.amountDue - COALESCE(i.amountPaid, 0)), 0) FROM InvoiceEntity i WHERE i.status = 'OPEN'")
|
||||
BigDecimal calculateTotalOutstanding();
|
||||
|
||||
/**
|
||||
* Calcule le montant total en retard
|
||||
*/
|
||||
@Query("SELECT COALESCE(SUM(i.amountDue - COALESCE(i.amountPaid, 0)), 0) FROM InvoiceEntity i WHERE i.status = 'OPEN' AND i.dueDate < :now")
|
||||
BigDecimal calculateTotalOverdue(@Param("now") LocalDateTime now);
|
||||
|
||||
/**
|
||||
* Calcule le taux de paiement (pourcentage de factures payées)
|
||||
*/
|
||||
@Query("""
|
||||
SELECT CAST(
|
||||
COUNT(CASE WHEN i.status = 'PAID' THEN 1 END) * 100.0 /
|
||||
NULLIF(COUNT(i), 0)
|
||||
AS DOUBLE
|
||||
)
|
||||
FROM InvoiceEntity i
|
||||
WHERE i.createdAt BETWEEN :startDate AND :endDate
|
||||
""")
|
||||
Double calculatePaymentRate(@Param("startDate") LocalDateTime startDate, @Param("endDate") LocalDateTime endDate);
|
||||
|
||||
// ===== RAPPORTS =====
|
||||
|
||||
/**
|
||||
* Rapport des revenus par mois
|
||||
*/
|
||||
@Query("""
|
||||
SELECT
|
||||
YEAR(i.paidAt) as year,
|
||||
MONTH(i.paidAt) as month,
|
||||
COUNT(i) as invoiceCount,
|
||||
SUM(i.amountPaid) as totalRevenue
|
||||
FROM InvoiceEntity i
|
||||
WHERE i.status = 'PAID'
|
||||
AND i.paidAt BETWEEN :startDate AND :endDate
|
||||
GROUP BY YEAR(i.paidAt), MONTH(i.paidAt)
|
||||
ORDER BY year DESC, month DESC
|
||||
""")
|
||||
List<Object[]> getMonthlyRevenueReport(@Param("startDate") LocalDateTime startDate, @Param("endDate") LocalDateTime endDate);
|
||||
|
||||
/**
|
||||
* Top des abonnements par revenus
|
||||
*/
|
||||
@Query("""
|
||||
SELECT
|
||||
i.subscription.stripeSubscriptionId,
|
||||
COUNT(i) as invoiceCount,
|
||||
SUM(i.amountPaid) as totalRevenue
|
||||
FROM InvoiceEntity i
|
||||
WHERE i.status = 'PAID'
|
||||
GROUP BY i.subscription.id, i.subscription.stripeSubscriptionId
|
||||
ORDER BY totalRevenue DESC
|
||||
""")
|
||||
List<Object[]> getTopSubscriptionsByRevenue(Pageable pageable);
|
||||
|
||||
// ===== OPÉRATIONS DE MAINTENANCE =====
|
||||
|
||||
/**
|
||||
* Met à jour les factures échues
|
||||
*/
|
||||
@Modifying
|
||||
@Query("UPDATE InvoiceEntity i SET i.attemptCount = i.attemptCount + 1 WHERE i.dueDate < :now AND i.status = 'OPEN'")
|
||||
int incrementAttemptsForOverdueInvoices(@Param("now") LocalDateTime now);
|
||||
|
||||
/**
|
||||
* Marque les factures anciennes comme irrécouvrables
|
||||
*/
|
||||
@Modifying
|
||||
@Query("UPDATE InvoiceEntity i SET i.status = 'UNCOLLECTIBLE' WHERE i.dueDate < :cutoffDate AND i.status = 'OPEN' AND i.attemptCount > :maxAttempts")
|
||||
int markOldInvoicesAsUncollectible(@Param("cutoffDate") LocalDateTime cutoffDate, @Param("maxAttempts") int maxAttempts);
|
||||
|
||||
/**
|
||||
* Archive les anciennes factures payées
|
||||
*/
|
||||
@Modifying
|
||||
@Query("DELETE FROM InvoiceEntity i WHERE i.status = 'PAID' AND i.paidAt < :cutoffDate")
|
||||
int deleteOldPaidInvoices(@Param("cutoffDate") LocalDateTime cutoffDate);
|
||||
|
||||
// ===== RECHERCHES AVANCÉES =====
|
||||
|
||||
/**
|
||||
* Recherche full-text dans les factures
|
||||
*/
|
||||
@Query("""
|
||||
SELECT i FROM InvoiceEntity i
|
||||
WHERE i.invoiceNumber LIKE %:searchTerm%
|
||||
OR i.stripeInvoiceId LIKE %:searchTerm%
|
||||
OR i.subscription.stripeSubscriptionId LIKE %:searchTerm%
|
||||
ORDER BY i.createdAt DESC
|
||||
""")
|
||||
List<InvoiceEntity> searchInvoices(@Param("searchTerm") String searchTerm);
|
||||
|
||||
/**
|
||||
* Trouve les factures par multiple critères
|
||||
*/
|
||||
@Query("""
|
||||
SELECT i FROM InvoiceEntity i
|
||||
WHERE (:status IS NULL OR i.status = :status)
|
||||
AND (:subscriptionId IS NULL OR i.subscription.id = :subscriptionId)
|
||||
AND (:minAmount IS NULL OR i.amountDue >= :minAmount)
|
||||
AND (:maxAmount IS NULL OR i.amountDue <= :maxAmount)
|
||||
AND (:startDate IS NULL OR i.createdAt >= :startDate)
|
||||
AND (:endDate IS NULL OR i.createdAt <= :endDate)
|
||||
ORDER BY i.createdAt DESC
|
||||
""")
|
||||
List<InvoiceEntity> findByMultipleCriteria(
|
||||
@Param("status") InvoiceStatusEntity status,
|
||||
@Param("subscriptionId") UUID subscriptionId,
|
||||
@Param("minAmount") BigDecimal minAmount,
|
||||
@Param("maxAmount") BigDecimal maxAmount,
|
||||
@Param("startDate") LocalDateTime startDate,
|
||||
@Param("endDate") LocalDateTime endDate
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,333 @@
|
||||
package com.dh7789dev.xpeditis.repository;
|
||||
|
||||
import com.dh7789dev.xpeditis.dto.app.EventStatus;
|
||||
import com.dh7789dev.xpeditis.entity.PaymentEventEntity;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Repository JPA pour les événements webhook de paiement
|
||||
*/
|
||||
@Repository
|
||||
public interface PaymentEventJpaRepository extends JpaRepository<PaymentEventEntity, UUID> {
|
||||
|
||||
// ===== RECHERCHES DE BASE =====
|
||||
|
||||
/**
|
||||
* Trouve un événement par son ID Stripe
|
||||
*/
|
||||
Optional<PaymentEventEntity> findByStripeEventId(String stripeEventId);
|
||||
|
||||
/**
|
||||
* Trouve les événements par type
|
||||
*/
|
||||
List<PaymentEventEntity> findByEventTypeOrderByCreatedAtDesc(String eventType);
|
||||
|
||||
/**
|
||||
* Trouve les événements par type avec limitation
|
||||
*/
|
||||
List<PaymentEventEntity> findByEventTypeOrderByCreatedAtDesc(String eventType, Pageable pageable);
|
||||
|
||||
/**
|
||||
* Vérifie l'existence d'un événement par ID Stripe
|
||||
*/
|
||||
boolean existsByStripeEventId(String stripeEventId);
|
||||
|
||||
/**
|
||||
* Vérifie l'existence et le statut d'un événement
|
||||
*/
|
||||
boolean existsByStripeEventIdAndStatus(String stripeEventId, EventStatus status);
|
||||
|
||||
// ===== RECHERCHES PAR STATUT =====
|
||||
|
||||
/**
|
||||
* Trouve les événements par statut triés par date de création
|
||||
*/
|
||||
List<PaymentEventEntity> findByStatusOrderByCreatedAtAsc(EventStatus status);
|
||||
|
||||
/**
|
||||
* Trouve les événements par statut triés par date de création (desc)
|
||||
*/
|
||||
List<PaymentEventEntity> findByStatusOrderByCreatedAtDesc(EventStatus status);
|
||||
|
||||
/**
|
||||
* Trouve les événements en attente
|
||||
*/
|
||||
@Query("SELECT e FROM PaymentEventEntity e WHERE e.status = 'PENDING' ORDER BY e.createdAt ASC")
|
||||
List<PaymentEventEntity> findPendingEvents();
|
||||
|
||||
/**
|
||||
* Trouve les événements échoués qui peuvent être retentés
|
||||
*/
|
||||
@Query("SELECT e FROM PaymentEventEntity e WHERE e.status = 'FAILED' AND e.retryCount < :maxRetries ORDER BY e.createdAt ASC")
|
||||
List<PaymentEventEntity> findFailedEventsForRetry(@Param("maxRetries") int maxRetries);
|
||||
|
||||
/**
|
||||
* Trouve les événements en cours de traitement depuis trop longtemps
|
||||
*/
|
||||
@Query("SELECT e FROM PaymentEventEntity e WHERE e.status = 'PROCESSING' AND e.createdAt < :cutoffTime ORDER BY e.createdAt ASC")
|
||||
List<PaymentEventEntity> findStuckProcessingEvents(@Param("cutoffTime") LocalDateTime cutoffTime);
|
||||
|
||||
// ===== RECHERCHES PAR ABONNEMENT =====
|
||||
|
||||
/**
|
||||
* Trouve les événements liés à un abonnement
|
||||
*/
|
||||
List<PaymentEventEntity> findBySubscriptionIdOrderByCreatedAtDesc(UUID subscriptionId);
|
||||
|
||||
/**
|
||||
* Trouve les événements récents d'un abonnement
|
||||
*/
|
||||
@Query("SELECT e FROM PaymentEventEntity e WHERE e.subscription.id = :subscriptionId AND e.createdAt > :since ORDER BY e.createdAt DESC")
|
||||
List<PaymentEventEntity> findRecentEventsBySubscription(@Param("subscriptionId") UUID subscriptionId, @Param("since") LocalDateTime since);
|
||||
|
||||
// ===== RECHERCHES PAR DATE =====
|
||||
|
||||
/**
|
||||
* Trouve les événements créés dans une période
|
||||
*/
|
||||
List<PaymentEventEntity> findByCreatedAtBetweenOrderByCreatedAtDesc(LocalDateTime startDate, LocalDateTime endDate);
|
||||
|
||||
/**
|
||||
* Trouve les événements traités dans une période
|
||||
*/
|
||||
@Query("SELECT e FROM PaymentEventEntity e WHERE e.processedAt BETWEEN :startDate AND :endDate ORDER BY e.processedAt DESC")
|
||||
List<PaymentEventEntity> findProcessedEventsBetween(@Param("startDate") LocalDateTime startDate, @Param("endDate") LocalDateTime endDate);
|
||||
|
||||
/**
|
||||
* Trouve les anciens événements traités
|
||||
*/
|
||||
@Query("SELECT e FROM PaymentEventEntity e WHERE e.status = 'PROCESSED' AND e.processedAt < :cutoffDate ORDER BY e.processedAt ASC")
|
||||
List<PaymentEventEntity> findOldProcessedEvents(@Param("cutoffDate") LocalDateTime cutoffDate);
|
||||
|
||||
// ===== RECHERCHES PAR TYPE D'ÉVÉNEMENT =====
|
||||
|
||||
/**
|
||||
* Trouve les événements de facturation
|
||||
*/
|
||||
@Query("SELECT e FROM PaymentEventEntity e WHERE e.eventType LIKE 'invoice.%' ORDER BY e.createdAt DESC")
|
||||
List<PaymentEventEntity> findInvoiceEvents();
|
||||
|
||||
/**
|
||||
* Trouve les événements d'abonnement
|
||||
*/
|
||||
@Query("SELECT e FROM PaymentEventEntity e WHERE e.eventType LIKE 'customer.subscription.%' ORDER BY e.createdAt DESC")
|
||||
List<PaymentEventEntity> findSubscriptionEvents();
|
||||
|
||||
/**
|
||||
* Trouve les événements de paiement
|
||||
*/
|
||||
@Query("SELECT e FROM PaymentEventEntity e WHERE e.eventType LIKE 'payment_%' OR e.eventType LIKE 'invoice.payment%' ORDER BY e.createdAt DESC")
|
||||
List<PaymentEventEntity> findPaymentEvents();
|
||||
|
||||
/**
|
||||
* Trouve les événements par multiple types
|
||||
*/
|
||||
List<PaymentEventEntity> findByEventTypeIn(List<String> eventTypes, org.springframework.data.domain.Sort sort);
|
||||
|
||||
/**
|
||||
* Trouve les événements par types et statuts
|
||||
*/
|
||||
@Query("SELECT e FROM PaymentEventEntity e WHERE e.eventType IN :eventTypes AND e.status IN :statuses ORDER BY e.createdAt DESC")
|
||||
List<PaymentEventEntity> findByEventTypeInAndStatusIn(@Param("eventTypes") List<String> eventTypes, @Param("statuses") List<EventStatus> statuses, org.springframework.data.domain.Sort sort);
|
||||
|
||||
// ===== RECHERCHES PAR TENTATIVES =====
|
||||
|
||||
/**
|
||||
* Trouve les événements avec beaucoup de tentatives
|
||||
*/
|
||||
@Query("SELECT e FROM PaymentEventEntity e WHERE e.retryCount > :minRetries ORDER BY e.retryCount DESC")
|
||||
List<PaymentEventEntity> findEventsWithManyRetries(@Param("minRetries") int minRetries);
|
||||
|
||||
/**
|
||||
* Trouve les événements ayant atteint le maximum de tentatives
|
||||
*/
|
||||
@Query("SELECT e FROM PaymentEventEntity e WHERE e.retryCount >= :maxRetries AND e.status = 'FAILED' ORDER BY e.createdAt ASC")
|
||||
List<PaymentEventEntity> findEventsAtMaxRetries(@Param("maxRetries") int maxRetries);
|
||||
|
||||
// ===== RECHERCHES PROBLÉMATIQUES =====
|
||||
|
||||
/**
|
||||
* Trouve les événements problématiques nécessitant une attention
|
||||
*/
|
||||
@Query("""
|
||||
SELECT e FROM PaymentEventEntity e
|
||||
WHERE (e.status = 'PROCESSING' AND e.createdAt < :stuckThreshold)
|
||||
OR (e.status = 'PENDING' AND e.createdAt < :oldPendingThreshold)
|
||||
OR (e.status = 'FAILED' AND e.retryCount > :highRetryCount)
|
||||
ORDER BY e.createdAt ASC
|
||||
""")
|
||||
List<PaymentEventEntity> findProblematicEvents(
|
||||
@Param("stuckThreshold") LocalDateTime stuckThreshold,
|
||||
@Param("oldPendingThreshold") LocalDateTime oldPendingThreshold,
|
||||
@Param("highRetryCount") int highRetryCount
|
||||
);
|
||||
|
||||
// ===== RECHERCHES AVANCÉES AVEC CRITÈRES MULTIPLES =====
|
||||
|
||||
/**
|
||||
* Trouve les événements par multiples statuts
|
||||
*/
|
||||
List<PaymentEventEntity> findByStatusIn(List<EventStatus> statuses, org.springframework.data.domain.Sort sort);
|
||||
|
||||
/**
|
||||
* Recherche full-text dans les événements
|
||||
*/
|
||||
@Query("""
|
||||
SELECT e FROM PaymentEventEntity e
|
||||
WHERE e.stripeEventId LIKE %:searchTerm%
|
||||
OR e.eventType LIKE %:searchTerm%
|
||||
OR e.errorMessage LIKE %:searchTerm%
|
||||
OR (e.subscription IS NOT NULL AND e.subscription.stripeSubscriptionId LIKE %:searchTerm%)
|
||||
ORDER BY e.createdAt DESC
|
||||
""")
|
||||
List<PaymentEventEntity> searchEvents(@Param("searchTerm") String searchTerm);
|
||||
|
||||
// ===== STATISTIQUES =====
|
||||
|
||||
/**
|
||||
* Compte les événements par statut
|
||||
*/
|
||||
long countByStatus(EventStatus status);
|
||||
|
||||
/**
|
||||
* Compte les événements traités depuis une date
|
||||
*/
|
||||
long countByStatusAndProcessedAtAfter(EventStatus status, LocalDateTime since);
|
||||
|
||||
/**
|
||||
* Compte les événements créés depuis une date
|
||||
*/
|
||||
long countByStatusAndCreatedAtAfter(EventStatus status, LocalDateTime since);
|
||||
|
||||
/**
|
||||
* Calcule le temps de traitement moyen
|
||||
*/
|
||||
@Query("""
|
||||
SELECT AVG(TIMESTAMPDIFF(SECOND, e.createdAt, e.processedAt))
|
||||
FROM PaymentEventEntity e
|
||||
WHERE e.status = 'PROCESSED'
|
||||
AND e.processedAt > :since
|
||||
AND e.processedAt IS NOT NULL
|
||||
""")
|
||||
Double calculateAverageProcessingTime(@Param("since") LocalDateTime since);
|
||||
|
||||
/**
|
||||
* Trouve le nombre maximum de tentatives
|
||||
*/
|
||||
@Query("SELECT MAX(e.retryCount) FROM PaymentEventEntity e WHERE e.createdAt > :since")
|
||||
Long findMaxRetryCount(@Param("since") LocalDateTime since);
|
||||
|
||||
// ===== RAPPORTS D'ERREURS =====
|
||||
|
||||
/**
|
||||
* Trouve les erreurs les plus fréquentes
|
||||
*/
|
||||
@Query("""
|
||||
SELECT e.errorMessage, COUNT(e) as errorCount
|
||||
FROM PaymentEventEntity e
|
||||
WHERE e.status = 'FAILED'
|
||||
AND e.createdAt > :since
|
||||
AND e.errorMessage IS NOT NULL
|
||||
GROUP BY e.errorMessage
|
||||
ORDER BY errorCount DESC
|
||||
""")
|
||||
List<String> findMostFrequentErrors(@Param("since") LocalDateTime since, Pageable pageable);
|
||||
|
||||
/**
|
||||
* Trouve les types d'événements les plus lents
|
||||
*/
|
||||
@Query("""
|
||||
SELECT e.eventType, AVG(TIMESTAMPDIFF(SECOND, e.createdAt, e.processedAt)) as avgProcessingTime
|
||||
FROM PaymentEventEntity e
|
||||
WHERE e.status = 'PROCESSED'
|
||||
AND e.processedAt > :since
|
||||
AND e.processedAt IS NOT NULL
|
||||
GROUP BY e.eventType
|
||||
ORDER BY avgProcessingTime DESC
|
||||
""")
|
||||
List<String> findSlowestEventTypes(@Param("since") LocalDateTime since, Pageable pageable);
|
||||
|
||||
// ===== OPÉRATIONS DE MAINTENANCE =====
|
||||
|
||||
/**
|
||||
* Supprime les anciens événements traités
|
||||
*/
|
||||
@Modifying
|
||||
@Query("DELETE FROM PaymentEventEntity e WHERE e.status = 'PROCESSED' AND e.processedAt < :cutoffDate")
|
||||
int deleteByStatusAndProcessedAtBefore(EventStatus status, LocalDateTime cutoffDate);
|
||||
|
||||
/**
|
||||
* Supprime les anciens événements échoués
|
||||
*/
|
||||
@Modifying
|
||||
@Query("DELETE FROM PaymentEventEntity e WHERE e.status = 'FAILED' AND e.retryCount > :maxRetries AND e.createdAt < :cutoffDate")
|
||||
int deleteFailedEventsOlderThan(@Param("cutoffDate") LocalDateTime cutoffDate, @Param("maxRetries") int maxRetries);
|
||||
|
||||
/**
|
||||
* Supprime les événements anciens (archivage)
|
||||
*/
|
||||
@Modifying
|
||||
@Query("DELETE FROM PaymentEventEntity e WHERE e.createdAt < :cutoffDate")
|
||||
int deleteByCreatedAtBefore(@Param("cutoffDate") LocalDateTime cutoffDate);
|
||||
|
||||
/**
|
||||
* Remet à zéro les événements bloqués en traitement
|
||||
*/
|
||||
@Modifying
|
||||
@Query("UPDATE PaymentEventEntity e SET e.status = 'PENDING' WHERE e.status = 'PROCESSING' AND e.createdAt < :cutoffTime")
|
||||
int resetStuckProcessingEvents(@Param("cutoffTime") LocalDateTime cutoffTime);
|
||||
|
||||
// ===== RAPPORTS ET ANALYTICS =====
|
||||
|
||||
/**
|
||||
* Rapport des événements par type et période
|
||||
*/
|
||||
@Query("""
|
||||
SELECT e.eventType, COUNT(e) as eventCount, e.status
|
||||
FROM PaymentEventEntity e
|
||||
WHERE e.createdAt BETWEEN :startDate AND :endDate
|
||||
GROUP BY e.eventType, e.status
|
||||
ORDER BY eventCount DESC
|
||||
""")
|
||||
List<Object[]> getEventReport(@Param("startDate") LocalDateTime startDate, @Param("endDate") LocalDateTime endDate);
|
||||
|
||||
/**
|
||||
* Rapport des performances de traitement
|
||||
*/
|
||||
@Query("""
|
||||
SELECT
|
||||
DATE(e.createdAt) as eventDate,
|
||||
COUNT(e) as totalEvents,
|
||||
COUNT(CASE WHEN e.status = 'PROCESSED' THEN 1 END) as processedEvents,
|
||||
COUNT(CASE WHEN e.status = 'FAILED' THEN 1 END) as failedEvents,
|
||||
AVG(CASE WHEN e.processedAt IS NOT NULL THEN TIMESTAMPDIFF(SECOND, e.createdAt, e.processedAt) END) as avgProcessingTime
|
||||
FROM PaymentEventEntity e
|
||||
WHERE e.createdAt BETWEEN :startDate AND :endDate
|
||||
GROUP BY DATE(e.createdAt)
|
||||
ORDER BY eventDate DESC
|
||||
""")
|
||||
List<Object[]> getProcessingPerformanceReport(@Param("startDate") LocalDateTime startDate, @Param("endDate") LocalDateTime endDate);
|
||||
|
||||
/**
|
||||
* Top des abonnements générant le plus d'événements
|
||||
*/
|
||||
@Query("""
|
||||
SELECT e.subscription.stripeSubscriptionId, COUNT(e) as eventCount
|
||||
FROM PaymentEventEntity e
|
||||
WHERE e.subscription IS NOT NULL
|
||||
AND e.createdAt > :since
|
||||
GROUP BY e.subscription.id, e.subscription.stripeSubscriptionId
|
||||
ORDER BY eventCount DESC
|
||||
""")
|
||||
List<Object[]> getTopEventGeneratingSubscriptions(@Param("since") LocalDateTime since, Pageable pageable);
|
||||
}
|
||||
@ -0,0 +1,371 @@
|
||||
package com.dh7789dev.xpeditis.repository;
|
||||
|
||||
import com.dh7789dev.xpeditis.entity.PaymentMethodEntity;
|
||||
import com.dh7789dev.xpeditis.entity.PaymentMethodTypeEntity;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Repository JPA pour les méthodes de paiement
|
||||
*/
|
||||
@Repository
|
||||
public interface PaymentMethodJpaRepository extends JpaRepository<PaymentMethodEntity, UUID> {
|
||||
|
||||
// ===== RECHERCHES DE BASE =====
|
||||
|
||||
/**
|
||||
* Trouve une méthode de paiement par son ID Stripe
|
||||
*/
|
||||
Optional<PaymentMethodEntity> findByStripePaymentMethodId(String stripePaymentMethodId);
|
||||
|
||||
/**
|
||||
* Trouve toutes les méthodes de paiement d'une entreprise
|
||||
*/
|
||||
List<PaymentMethodEntity> findByCompanyIdOrderByCreatedAtDesc(UUID companyId);
|
||||
|
||||
/**
|
||||
* Trouve les méthodes de paiement actives d'une entreprise
|
||||
*/
|
||||
@Query("SELECT pm FROM PaymentMethodEntity pm WHERE pm.companyId = :companyId ORDER BY pm.isDefault DESC, pm.createdAt DESC")
|
||||
List<PaymentMethodEntity> findActivePaymentMethodsByCompany(@Param("companyId") UUID companyId);
|
||||
|
||||
/**
|
||||
* Vérifie l'existence d'une méthode de paiement par ID Stripe
|
||||
*/
|
||||
boolean existsByStripePaymentMethodId(String stripePaymentMethodId);
|
||||
|
||||
// ===== RECHERCHES PAR TYPE =====
|
||||
|
||||
/**
|
||||
* Trouve les méthodes de paiement par type
|
||||
*/
|
||||
List<PaymentMethodEntity> findByTypeOrderByCreatedAtDesc(PaymentMethodTypeEntity type);
|
||||
|
||||
/**
|
||||
* Trouve les méthodes de paiement par type pour une entreprise
|
||||
*/
|
||||
List<PaymentMethodEntity> findByCompanyIdAndTypeOrderByCreatedAtDesc(UUID companyId, PaymentMethodTypeEntity type);
|
||||
|
||||
/**
|
||||
* Trouve toutes les cartes de crédit
|
||||
*/
|
||||
@Query("SELECT pm FROM PaymentMethodEntity pm WHERE pm.type = 'CARD' ORDER BY pm.createdAt DESC")
|
||||
List<PaymentMethodEntity> findAllCards();
|
||||
|
||||
/**
|
||||
* Trouve les cartes d'une entreprise
|
||||
*/
|
||||
@Query("SELECT pm FROM PaymentMethodEntity pm WHERE pm.companyId = :companyId AND pm.type = 'CARD' ORDER BY pm.isDefault DESC, pm.createdAt DESC")
|
||||
List<PaymentMethodEntity> findCardsByCompany(@Param("companyId") UUID companyId);
|
||||
|
||||
/**
|
||||
* Trouve les méthodes de prélèvement SEPA
|
||||
*/
|
||||
@Query("SELECT pm FROM PaymentMethodEntity pm WHERE pm.type = 'SEPA_DEBIT' ORDER BY pm.createdAt DESC")
|
||||
List<PaymentMethodEntity> findSepaDebitMethods();
|
||||
|
||||
// ===== RECHERCHES PAR DÉFAUT =====
|
||||
|
||||
/**
|
||||
* Trouve la méthode de paiement par défaut d'une entreprise
|
||||
*/
|
||||
Optional<PaymentMethodEntity> findByCompanyIdAndIsDefaultTrue(UUID companyId);
|
||||
|
||||
/**
|
||||
* Trouve toutes les méthodes par défaut (pour debug/maintenance)
|
||||
*/
|
||||
@Query("SELECT pm FROM PaymentMethodEntity pm WHERE pm.isDefault = true ORDER BY pm.companyId, pm.createdAt DESC")
|
||||
List<PaymentMethodEntity> findAllDefaultPaymentMethods();
|
||||
|
||||
/**
|
||||
* Compte les méthodes par défaut par entreprise (devrait être 0 ou 1)
|
||||
*/
|
||||
long countByCompanyIdAndIsDefaultTrue(UUID companyId);
|
||||
|
||||
// ===== RECHERCHES PAR EXPIRATION =====
|
||||
|
||||
/**
|
||||
* Trouve les cartes expirant bientôt
|
||||
*/
|
||||
@Query("""
|
||||
SELECT pm FROM PaymentMethodEntity pm
|
||||
WHERE pm.type = 'CARD'
|
||||
AND pm.cardExpYear IS NOT NULL
|
||||
AND pm.cardExpMonth IS NOT NULL
|
||||
AND (
|
||||
pm.cardExpYear < :currentYear
|
||||
OR (pm.cardExpYear = :currentYear AND pm.cardExpMonth <= :currentMonth + :monthsAhead)
|
||||
)
|
||||
ORDER BY pm.cardExpYear ASC, pm.cardExpMonth ASC
|
||||
""")
|
||||
List<PaymentMethodEntity> findCardsExpiringSoon(
|
||||
@Param("currentYear") int currentYear,
|
||||
@Param("currentMonth") int currentMonth,
|
||||
@Param("monthsAhead") int monthsAhead
|
||||
);
|
||||
|
||||
/**
|
||||
* Trouve les cartes expirées
|
||||
*/
|
||||
@Query("""
|
||||
SELECT pm FROM PaymentMethodEntity pm
|
||||
WHERE pm.type = 'CARD'
|
||||
AND pm.cardExpYear IS NOT NULL
|
||||
AND pm.cardExpMonth IS NOT NULL
|
||||
AND (
|
||||
pm.cardExpYear < :currentYear
|
||||
OR (pm.cardExpYear = :currentYear AND pm.cardExpMonth < :currentMonth)
|
||||
)
|
||||
ORDER BY pm.cardExpYear DESC, pm.cardExpMonth DESC
|
||||
""")
|
||||
List<PaymentMethodEntity> findExpiredCards(
|
||||
@Param("currentYear") int currentYear,
|
||||
@Param("currentMonth") int currentMonth
|
||||
);
|
||||
|
||||
/**
|
||||
* Trouve les cartes d'une entreprise expirant dans les X mois
|
||||
*/
|
||||
@Query("""
|
||||
SELECT pm FROM PaymentMethodEntity pm
|
||||
WHERE pm.companyId = :companyId
|
||||
AND pm.type = 'CARD'
|
||||
AND pm.cardExpYear IS NOT NULL
|
||||
AND pm.cardExpMonth IS NOT NULL
|
||||
AND (
|
||||
pm.cardExpYear < :currentYear
|
||||
OR (pm.cardExpYear = :currentYear AND pm.cardExpMonth <= :currentMonth + :monthsAhead)
|
||||
)
|
||||
ORDER BY pm.cardExpYear ASC, pm.cardExpMonth ASC
|
||||
""")
|
||||
List<PaymentMethodEntity> findCompanyCardsExpiringSoon(
|
||||
@Param("companyId") UUID companyId,
|
||||
@Param("currentYear") int currentYear,
|
||||
@Param("currentMonth") int currentMonth,
|
||||
@Param("monthsAhead") int monthsAhead
|
||||
);
|
||||
|
||||
// ===== RECHERCHES PAR MARQUE DE CARTE =====
|
||||
|
||||
/**
|
||||
* Trouve les cartes par marque
|
||||
*/
|
||||
List<PaymentMethodEntity> findByCardBrandIgnoreCaseOrderByCreatedAtDesc(String cardBrand);
|
||||
|
||||
/**
|
||||
* Trouve les cartes d'une entreprise par marque
|
||||
*/
|
||||
List<PaymentMethodEntity> findByCompanyIdAndCardBrandIgnoreCaseOrderByCreatedAtDesc(UUID companyId, String cardBrand);
|
||||
|
||||
/**
|
||||
* Trouve les cartes par les 4 derniers chiffres
|
||||
*/
|
||||
List<PaymentMethodEntity> findByCardLast4OrderByCreatedAtDesc(String cardLast4);
|
||||
|
||||
/**
|
||||
* Trouve une carte spécifique d'une entreprise par marque et 4 derniers chiffres
|
||||
*/
|
||||
Optional<PaymentMethodEntity> findByCompanyIdAndCardBrandIgnoreCaseAndCardLast4(
|
||||
UUID companyId, String cardBrand, String cardLast4
|
||||
);
|
||||
|
||||
// ===== RECHERCHES PAR BANQUE =====
|
||||
|
||||
/**
|
||||
* Trouve les méthodes de paiement par banque
|
||||
*/
|
||||
List<PaymentMethodEntity> findByBankNameIgnoreCaseOrderByCreatedAtDesc(String bankName);
|
||||
|
||||
/**
|
||||
* Trouve les méthodes d'une entreprise par banque
|
||||
*/
|
||||
List<PaymentMethodEntity> findByCompanyIdAndBankNameIgnoreCaseOrderByCreatedAtDesc(UUID companyId, String bankName);
|
||||
|
||||
// ===== STATISTIQUES =====
|
||||
|
||||
/**
|
||||
* Compte les méthodes de paiement par entreprise
|
||||
*/
|
||||
long countByCompanyId(UUID companyId);
|
||||
|
||||
/**
|
||||
* Compte les méthodes de paiement par type
|
||||
*/
|
||||
long countByType(PaymentMethodTypeEntity type);
|
||||
|
||||
/**
|
||||
* Compte les cartes par marque
|
||||
*/
|
||||
long countByCardBrandIgnoreCase(String cardBrand);
|
||||
|
||||
/**
|
||||
* Statistiques par type de méthode de paiement
|
||||
*/
|
||||
@Query("""
|
||||
SELECT pm.type, COUNT(pm) as methodCount
|
||||
FROM PaymentMethodEntity pm
|
||||
GROUP BY pm.type
|
||||
ORDER BY methodCount DESC
|
||||
""")
|
||||
List<Object[]> getPaymentMethodStatistics();
|
||||
|
||||
/**
|
||||
* Statistiques par marque de carte
|
||||
*/
|
||||
@Query("""
|
||||
SELECT pm.cardBrand, COUNT(pm) as cardCount
|
||||
FROM PaymentMethodEntity pm
|
||||
WHERE pm.type = 'CARD' AND pm.cardBrand IS NOT NULL
|
||||
GROUP BY pm.cardBrand
|
||||
ORDER BY cardCount DESC
|
||||
""")
|
||||
List<Object[]> getCardBrandStatistics();
|
||||
|
||||
// ===== RAPPORTS =====
|
||||
|
||||
/**
|
||||
* Rapport des méthodes de paiement par entreprise
|
||||
*/
|
||||
@Query("""
|
||||
SELECT pm.companyId, pm.type, COUNT(pm) as methodCount
|
||||
FROM PaymentMethodEntity pm
|
||||
GROUP BY pm.companyId, pm.type
|
||||
ORDER BY pm.companyId, methodCount DESC
|
||||
""")
|
||||
List<Object[]> getPaymentMethodsByCompanyReport();
|
||||
|
||||
/**
|
||||
* Rapport des expirations de cartes par mois
|
||||
*/
|
||||
@Query("""
|
||||
SELECT pm.cardExpYear, pm.cardExpMonth, COUNT(pm) as expiringCards
|
||||
FROM PaymentMethodEntity pm
|
||||
WHERE pm.type = 'CARD'
|
||||
AND pm.cardExpYear IS NOT NULL
|
||||
AND pm.cardExpMonth IS NOT NULL
|
||||
GROUP BY pm.cardExpYear, pm.cardExpMonth
|
||||
ORDER BY pm.cardExpYear ASC, pm.cardExpMonth ASC
|
||||
""")
|
||||
List<Object[]> getCardExpirationReport();
|
||||
|
||||
// ===== OPÉRATIONS DE MAINTENANCE =====
|
||||
|
||||
/**
|
||||
* Met à jour les méthodes par défaut d'une entreprise (retire le statut de défaut)
|
||||
*/
|
||||
@Modifying
|
||||
@Query("UPDATE PaymentMethodEntity pm SET pm.isDefault = false WHERE pm.companyId = :companyId AND pm.isDefault = true")
|
||||
int removeAllDefaultsForCompany(@Param("companyId") UUID companyId);
|
||||
|
||||
/**
|
||||
* Définit une méthode comme méthode par défaut (et retire le statut des autres)
|
||||
*/
|
||||
@Modifying
|
||||
@Query("UPDATE PaymentMethodEntity pm SET pm.isDefault = CASE WHEN pm.id = :paymentMethodId THEN true ELSE false END WHERE pm.companyId = :companyId")
|
||||
int setAsDefaultPaymentMethod(@Param("companyId") UUID companyId, @Param("paymentMethodId") UUID paymentMethodId);
|
||||
|
||||
/**
|
||||
* Supprime les anciennes méthodes de paiement non utilisées
|
||||
*/
|
||||
@Modifying
|
||||
@Query("DELETE FROM PaymentMethodEntity pm WHERE pm.createdAt < :cutoffDate AND pm.isDefault = false")
|
||||
int deleteOldUnusedPaymentMethods(@Param("cutoffDate") LocalDateTime cutoffDate);
|
||||
|
||||
// ===== VALIDATION ET VÉRIFICATIONS =====
|
||||
|
||||
/**
|
||||
* Trouve les entreprises avec plusieurs méthodes par défaut (problème de données)
|
||||
*/
|
||||
@Query("""
|
||||
SELECT pm.companyId, COUNT(pm) as defaultCount
|
||||
FROM PaymentMethodEntity pm
|
||||
WHERE pm.isDefault = true
|
||||
GROUP BY pm.companyId
|
||||
HAVING COUNT(pm) > 1
|
||||
""")
|
||||
List<Object[]> findCompaniesWithMultipleDefaults();
|
||||
|
||||
/**
|
||||
* Trouve les méthodes de paiement orphelines (entreprise inexistante)
|
||||
*/
|
||||
@Query("""
|
||||
SELECT pm FROM PaymentMethodEntity pm
|
||||
WHERE pm.companyId NOT IN (SELECT c.id FROM Company c)
|
||||
""")
|
||||
List<PaymentMethodEntity> findOrphanedPaymentMethods();
|
||||
|
||||
/**
|
||||
* Trouve les entreprises sans méthode de paiement
|
||||
*/
|
||||
@Query("""
|
||||
SELECT c.id, c.name FROM Company c
|
||||
WHERE c.id NOT IN (SELECT DISTINCT pm.companyId FROM PaymentMethodEntity pm)
|
||||
""")
|
||||
List<Object[]> findCompaniesWithoutPaymentMethods();
|
||||
|
||||
// ===== RECHERCHES AVANCÉES =====
|
||||
|
||||
/**
|
||||
* Recherche full-text dans les méthodes de paiement
|
||||
*/
|
||||
@Query("""
|
||||
SELECT pm FROM PaymentMethodEntity pm
|
||||
WHERE pm.stripePaymentMethodId LIKE %:searchTerm%
|
||||
OR LOWER(pm.cardBrand) LIKE LOWER(CONCAT('%', :searchTerm, '%'))
|
||||
OR pm.cardLast4 LIKE %:searchTerm%
|
||||
OR LOWER(pm.bankName) LIKE LOWER(CONCAT('%', :searchTerm, '%'))
|
||||
ORDER BY pm.createdAt DESC
|
||||
""")
|
||||
List<PaymentMethodEntity> searchPaymentMethods(@Param("searchTerm") String searchTerm);
|
||||
|
||||
/**
|
||||
* Trouve les méthodes de paiement par multiple critères
|
||||
*/
|
||||
@Query("""
|
||||
SELECT pm FROM PaymentMethodEntity pm
|
||||
WHERE (:companyId IS NULL OR pm.companyId = :companyId)
|
||||
AND (:type IS NULL OR pm.type = :type)
|
||||
AND (:isDefault IS NULL OR pm.isDefault = :isDefault)
|
||||
AND (:cardBrand IS NULL OR LOWER(pm.cardBrand) = LOWER(:cardBrand))
|
||||
ORDER BY pm.isDefault DESC, pm.createdAt DESC
|
||||
""")
|
||||
List<PaymentMethodEntity> findByMultipleCriteria(
|
||||
@Param("companyId") UUID companyId,
|
||||
@Param("type") PaymentMethodTypeEntity type,
|
||||
@Param("isDefault") Boolean isDefault,
|
||||
@Param("cardBrand") String cardBrand
|
||||
);
|
||||
|
||||
/**
|
||||
* Trouve les méthodes de paiement supportant les paiements récurrents
|
||||
*/
|
||||
@Query("SELECT pm FROM PaymentMethodEntity pm WHERE pm.type IN ('CARD', 'SEPA_DEBIT') ORDER BY pm.isDefault DESC, pm.createdAt DESC")
|
||||
List<PaymentMethodEntity> findRecurringPaymentCapableMethods();
|
||||
|
||||
/**
|
||||
* Trouve les méthodes de paiement nécessitant une attention (expirées ou expirant bientôt)
|
||||
*/
|
||||
@Query("""
|
||||
SELECT pm FROM PaymentMethodEntity pm
|
||||
WHERE pm.type = 'CARD'
|
||||
AND pm.cardExpYear IS NOT NULL
|
||||
AND pm.cardExpMonth IS NOT NULL
|
||||
AND (
|
||||
pm.cardExpYear < :currentYear
|
||||
OR (pm.cardExpYear = :currentYear AND pm.cardExpMonth <= :currentMonth + 2)
|
||||
)
|
||||
ORDER BY pm.isDefault DESC, pm.cardExpYear ASC, pm.cardExpMonth ASC
|
||||
""")
|
||||
List<PaymentMethodEntity> findPaymentMethodsRequiringAttention(
|
||||
@Param("currentYear") int currentYear,
|
||||
@Param("currentMonth") int currentMonth
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,285 @@
|
||||
package com.dh7789dev.xpeditis.repository;
|
||||
|
||||
import com.dh7789dev.xpeditis.entity.PlanEntity;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Repository JPA pour les plans d'abonnement
|
||||
*/
|
||||
@Repository
|
||||
public interface PlanJpaRepository extends JpaRepository<PlanEntity, UUID> {
|
||||
|
||||
// ===== RECHERCHES DE BASE =====
|
||||
|
||||
/**
|
||||
* Trouve un plan par son nom
|
||||
*/
|
||||
Optional<PlanEntity> findByName(String name);
|
||||
|
||||
/**
|
||||
* Trouve un plan par son type
|
||||
*/
|
||||
Optional<PlanEntity> findByType(String type);
|
||||
|
||||
/**
|
||||
* Trouve un plan par son ID de prix Stripe (mensuel)
|
||||
*/
|
||||
Optional<PlanEntity> findByStripePriceIdMonthly(String stripePriceIdMonthly);
|
||||
|
||||
/**
|
||||
* Trouve un plan par son ID de prix Stripe (annuel)
|
||||
*/
|
||||
Optional<PlanEntity> findByStripePriceIdYearly(String stripePriceIdYearly);
|
||||
|
||||
/**
|
||||
* Trouve un plan par l'un de ses ID de prix Stripe
|
||||
*/
|
||||
@Query("SELECT p FROM PlanEntity p WHERE p.stripePriceIdMonthly = :priceId OR p.stripePriceIdYearly = :priceId")
|
||||
Optional<PlanEntity> findByAnyStripePriceId(@Param("priceId") String priceId);
|
||||
|
||||
/**
|
||||
* Vérifie l'existence d'un plan par nom
|
||||
*/
|
||||
boolean existsByName(String name);
|
||||
|
||||
/**
|
||||
* Vérifie l'existence d'un plan par type
|
||||
*/
|
||||
boolean existsByType(String type);
|
||||
|
||||
// ===== RECHERCHES PAR STATUT =====
|
||||
|
||||
/**
|
||||
* Trouve tous les plans actifs ordonnés par ordre d'affichage
|
||||
*/
|
||||
List<PlanEntity> findByIsActiveTrueOrderByDisplayOrderAsc();
|
||||
|
||||
/**
|
||||
* Trouve tous les plans disponibles pour la souscription
|
||||
*/
|
||||
@Query("SELECT p FROM PlanEntity p WHERE p.isActive = true ORDER BY p.displayOrder ASC, p.monthlyPrice ASC")
|
||||
List<PlanEntity> findAvailablePlans();
|
||||
|
||||
/**
|
||||
* Trouve tous les plans inactifs
|
||||
*/
|
||||
List<PlanEntity> findByIsActiveFalseOrderByNameAsc();
|
||||
|
||||
/**
|
||||
* Trouve les plans actifs avec pagination
|
||||
*/
|
||||
Page<PlanEntity> findByIsActiveTrue(Pageable pageable);
|
||||
|
||||
// ===== RECHERCHES PAR PRIX =====
|
||||
|
||||
/**
|
||||
* Trouve les plans dans une fourchette de prix mensuel
|
||||
*/
|
||||
List<PlanEntity> findByMonthlyPriceBetweenAndIsActiveTrueOrderByMonthlyPriceAsc(BigDecimal minPrice, BigDecimal maxPrice);
|
||||
|
||||
/**
|
||||
* Trouve les plans dans une fourchette de prix annuel
|
||||
*/
|
||||
List<PlanEntity> findByYearlyPriceBetweenAndIsActiveTrueOrderByYearlyPriceAsc(BigDecimal minPrice, BigDecimal maxPrice);
|
||||
|
||||
/**
|
||||
* Trouve les plans gratuits
|
||||
*/
|
||||
@Query("SELECT p FROM PlanEntity p WHERE (p.monthlyPrice = 0 OR p.monthlyPrice IS NULL) AND (p.yearlyPrice = 0 OR p.yearlyPrice IS NULL) AND p.isActive = true")
|
||||
List<PlanEntity> findFreePlans();
|
||||
|
||||
/**
|
||||
* Trouve les plans payants ordonnés par prix croissant
|
||||
*/
|
||||
@Query("SELECT p FROM PlanEntity p WHERE p.monthlyPrice > 0 AND p.isActive = true ORDER BY p.monthlyPrice ASC")
|
||||
List<PlanEntity> findPaidPlansOrderByPrice();
|
||||
|
||||
// ===== RECHERCHES PAR FONCTIONNALITÉS =====
|
||||
|
||||
/**
|
||||
* Trouve les plans incluant une fonctionnalité spécifique
|
||||
*/
|
||||
@Query("SELECT p FROM PlanEntity p WHERE JSON_CONTAINS(p.features, :feature, '$') = 1 AND p.isActive = true")
|
||||
List<PlanEntity> findPlansWithFeature(@Param("feature") String feature);
|
||||
|
||||
/**
|
||||
* Trouve les plans avec un nombre spécifique de fonctionnalités
|
||||
*/
|
||||
@Query("SELECT p FROM PlanEntity p WHERE JSON_LENGTH(p.features) = :featureCount AND p.isActive = true")
|
||||
List<PlanEntity> findPlansByFeatureCount(@Param("featureCount") int featureCount);
|
||||
|
||||
/**
|
||||
* Trouve les plans avec un nombre minimum de fonctionnalités
|
||||
*/
|
||||
@Query("SELECT p FROM PlanEntity p WHERE JSON_LENGTH(p.features) >= :minFeatures AND p.isActive = true ORDER BY JSON_LENGTH(p.features) ASC")
|
||||
List<PlanEntity> findPlansWithMinimumFeatures(@Param("minFeatures") int minFeatures);
|
||||
|
||||
// ===== RECHERCHES PAR UTILISATEURS =====
|
||||
|
||||
/**
|
||||
* Trouve les plans supportant un nombre d'utilisateurs spécifique
|
||||
*/
|
||||
List<PlanEntity> findByMaxUsersGreaterThanEqualAndIsActiveTrueOrderByMaxUsersAsc(Integer minUsers);
|
||||
|
||||
/**
|
||||
* Trouve les plans avec utilisateurs illimités
|
||||
*/
|
||||
@Query("SELECT p FROM PlanEntity p WHERE p.maxUsers = -1 AND p.isActive = true")
|
||||
List<PlanEntity> findUnlimitedUserPlans();
|
||||
|
||||
/**
|
||||
* Trouve le plan le plus économique pour un nombre d'utilisateurs donné
|
||||
*/
|
||||
@Query("SELECT p FROM PlanEntity p WHERE (p.maxUsers >= :userCount OR p.maxUsers = -1) AND p.isActive = true ORDER BY p.monthlyPrice ASC LIMIT 1")
|
||||
Optional<PlanEntity> findCheapestPlanForUsers(@Param("userCount") int userCount);
|
||||
|
||||
// ===== RECHERCHES PAR PÉRIODE D'ESSAI =====
|
||||
|
||||
/**
|
||||
* Trouve les plans avec période d'essai
|
||||
*/
|
||||
@Query("SELECT p FROM PlanEntity p WHERE p.trialDurationDays > 0 AND p.isActive = true")
|
||||
List<PlanEntity> findPlansWithTrial();
|
||||
|
||||
/**
|
||||
* Trouve les plans par durée d'essai
|
||||
*/
|
||||
List<PlanEntity> findByTrialDurationDaysAndIsActiveTrueOrderByDisplayOrderAsc(Integer trialDays);
|
||||
|
||||
// ===== RECHERCHES POUR RECOMMANDATIONS =====
|
||||
|
||||
/**
|
||||
* Trouve les plans recommandés (avec métadonnées spécifiques)
|
||||
*/
|
||||
@Query("SELECT p FROM PlanEntity p WHERE JSON_EXTRACT(p.metadata, '$.recommended') = true AND p.isActive = true ORDER BY p.displayOrder ASC")
|
||||
List<PlanEntity> findRecommendedPlans();
|
||||
|
||||
/**
|
||||
* Trouve les plans populaires
|
||||
*/
|
||||
@Query("SELECT p FROM PlanEntity p WHERE JSON_EXTRACT(p.metadata, '$.popular') = true AND p.isActive = true ORDER BY p.displayOrder ASC")
|
||||
List<PlanEntity> findPopularPlans();
|
||||
|
||||
/**
|
||||
* Trouve les plans pour entreprises
|
||||
*/
|
||||
@Query("SELECT p FROM PlanEntity p WHERE p.maxUsers = -1 OR p.maxUsers > 50 AND p.isActive = true ORDER BY p.monthlyPrice ASC")
|
||||
List<PlanEntity> findEnterprisePlans();
|
||||
|
||||
// ===== COMPARAISON DE PLANS =====
|
||||
|
||||
/**
|
||||
* Trouve les plans dans une gamme de prix pour comparaison
|
||||
*/
|
||||
@Query("""
|
||||
SELECT p FROM PlanEntity p
|
||||
WHERE p.isActive = true
|
||||
AND p.monthlyPrice BETWEEN :basePrice * 0.5 AND :basePrice * 2
|
||||
ORDER BY p.monthlyPrice ASC
|
||||
""")
|
||||
List<PlanEntity> findPlansForComparison(@Param("basePrice") BigDecimal basePrice);
|
||||
|
||||
/**
|
||||
* Trouve le plan supérieur recommandé pour un upgrade
|
||||
*/
|
||||
@Query("""
|
||||
SELECT p FROM PlanEntity p
|
||||
WHERE p.monthlyPrice > :currentPrice
|
||||
AND p.isActive = true
|
||||
ORDER BY p.monthlyPrice ASC
|
||||
LIMIT 1
|
||||
""")
|
||||
Optional<PlanEntity> findNextTierPlan(@Param("currentPrice") BigDecimal currentPrice);
|
||||
|
||||
/**
|
||||
* Trouve le plan inférieur pour un downgrade
|
||||
*/
|
||||
@Query("""
|
||||
SELECT p FROM PlanEntity p
|
||||
WHERE p.monthlyPrice < :currentPrice
|
||||
AND p.isActive = true
|
||||
ORDER BY p.monthlyPrice DESC
|
||||
LIMIT 1
|
||||
""")
|
||||
Optional<PlanEntity> findPreviousTierPlan(@Param("currentPrice") BigDecimal currentPrice);
|
||||
|
||||
// ===== STATISTIQUES =====
|
||||
|
||||
/**
|
||||
* Compte les plans actifs
|
||||
*/
|
||||
long countByIsActiveTrue();
|
||||
|
||||
/**
|
||||
* Trouve le prix mensuel moyen des plans actifs
|
||||
*/
|
||||
@Query("SELECT AVG(p.monthlyPrice) FROM PlanEntity p WHERE p.monthlyPrice > 0 AND p.isActive = true")
|
||||
Double findAverageMonthlyPrice();
|
||||
|
||||
/**
|
||||
* Trouve le prix mensuel médian des plans actifs
|
||||
*/
|
||||
@Query("""
|
||||
SELECT p.monthlyPrice
|
||||
FROM PlanEntity p
|
||||
WHERE p.monthlyPrice > 0 AND p.isActive = true
|
||||
ORDER BY p.monthlyPrice
|
||||
LIMIT 1 OFFSET (SELECT COUNT(*) FROM PlanEntity WHERE monthlyPrice > 0 AND isActive = true) / 2
|
||||
""")
|
||||
BigDecimal findMedianMonthlyPrice();
|
||||
|
||||
// ===== RECHERCHES AVANCÉES =====
|
||||
|
||||
/**
|
||||
* Recherche full-text dans les plans
|
||||
*/
|
||||
@Query("""
|
||||
SELECT p FROM PlanEntity p
|
||||
WHERE LOWER(p.name) LIKE LOWER(CONCAT('%', :searchTerm, '%'))
|
||||
OR LOWER(p.type) LIKE LOWER(CONCAT('%', :searchTerm, '%'))
|
||||
OR JSON_SEARCH(p.features, 'one', CONCAT('%', :searchTerm, '%')) IS NOT NULL
|
||||
ORDER BY p.displayOrder ASC
|
||||
""")
|
||||
List<PlanEntity> searchPlans(@Param("searchTerm") String searchTerm);
|
||||
|
||||
/**
|
||||
* Trouve les plans par multiple critères
|
||||
*/
|
||||
@Query("""
|
||||
SELECT p FROM PlanEntity p
|
||||
WHERE (:isActive IS NULL OR p.isActive = :isActive)
|
||||
AND (:minPrice IS NULL OR p.monthlyPrice >= :minPrice)
|
||||
AND (:maxPrice IS NULL OR p.monthlyPrice <= :maxPrice)
|
||||
AND (:minUsers IS NULL OR p.maxUsers >= :minUsers OR p.maxUsers = -1)
|
||||
ORDER BY p.displayOrder ASC, p.monthlyPrice ASC
|
||||
""")
|
||||
List<PlanEntity> findByMultipleCriteria(
|
||||
@Param("isActive") Boolean isActive,
|
||||
@Param("minPrice") BigDecimal minPrice,
|
||||
@Param("maxPrice") BigDecimal maxPrice,
|
||||
@Param("minUsers") Integer minUsers
|
||||
);
|
||||
|
||||
/**
|
||||
* Trouve les plans les plus utilisés (basé sur les abonnements)
|
||||
*/
|
||||
@Query("""
|
||||
SELECT p, COUNT(s) as subscriptionCount
|
||||
FROM PlanEntity p
|
||||
LEFT JOIN SubscriptionEntity s ON (s.stripePriceId = p.stripePriceIdMonthly OR s.stripePriceId = p.stripePriceIdYearly)
|
||||
WHERE p.isActive = true
|
||||
GROUP BY p
|
||||
ORDER BY subscriptionCount DESC
|
||||
""")
|
||||
List<Object[]> findMostUsedPlans(Pageable pageable);
|
||||
}
|
||||
@ -0,0 +1,250 @@
|
||||
package com.dh7789dev.xpeditis.repository;
|
||||
|
||||
import com.dh7789dev.xpeditis.entity.SubscriptionEntity;
|
||||
import com.dh7789dev.xpeditis.entity.SubscriptionStatusEntity;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Repository JPA pour les abonnements Stripe
|
||||
*/
|
||||
@Repository
|
||||
public interface SubscriptionJpaRepository extends JpaRepository<SubscriptionEntity, UUID> {
|
||||
|
||||
// ===== RECHERCHES DE BASE =====
|
||||
|
||||
/**
|
||||
* Trouve un abonnement par son ID Stripe
|
||||
*/
|
||||
Optional<SubscriptionEntity> findByStripeSubscriptionId(String stripeSubscriptionId);
|
||||
|
||||
/**
|
||||
* Trouve un abonnement par son ID client Stripe
|
||||
*/
|
||||
List<SubscriptionEntity> findByStripeCustomerId(String stripeCustomerId);
|
||||
|
||||
/**
|
||||
* Trouve un abonnement par son ID de prix Stripe
|
||||
*/
|
||||
List<SubscriptionEntity> findByStripePriceId(String stripePriceId);
|
||||
|
||||
/**
|
||||
* Vérifie l'existence d'un abonnement par ID Stripe
|
||||
*/
|
||||
boolean existsByStripeSubscriptionId(String stripeSubscriptionId);
|
||||
|
||||
// ===== RECHERCHES PAR STATUT =====
|
||||
|
||||
/**
|
||||
* Trouve les abonnements par statut
|
||||
*/
|
||||
List<SubscriptionEntity> findByStatusOrderByCreatedAtDesc(SubscriptionStatusEntity status);
|
||||
|
||||
/**
|
||||
* Trouve les abonnements par statut avec pagination
|
||||
*/
|
||||
Page<SubscriptionEntity> findByStatus(SubscriptionStatusEntity status, Pageable pageable);
|
||||
|
||||
/**
|
||||
* Trouve les abonnements actifs
|
||||
*/
|
||||
@Query("SELECT s FROM SubscriptionEntity s WHERE s.status = 'ACTIVE' ORDER BY s.createdAt DESC")
|
||||
List<SubscriptionEntity> findActiveSubscriptions();
|
||||
|
||||
/**
|
||||
* Trouve les abonnements en période d'essai
|
||||
*/
|
||||
@Query("SELECT s FROM SubscriptionEntity s WHERE s.status = 'TRIALING' ORDER BY s.trialEndDate ASC")
|
||||
List<SubscriptionEntity> findTrialSubscriptions();
|
||||
|
||||
/**
|
||||
* Trouve les abonnements nécessitant une attention (problèmes de paiement)
|
||||
*/
|
||||
@Query("SELECT s FROM SubscriptionEntity s WHERE s.status IN ('PAST_DUE', 'UNPAID', 'INCOMPLETE') ORDER BY s.nextBillingDate ASC")
|
||||
List<SubscriptionEntity> findSubscriptionsRequiringAttention();
|
||||
|
||||
// ===== RECHERCHES PAR DATE =====
|
||||
|
||||
/**
|
||||
* Trouve les abonnements dont la période d'essai se termine bientôt
|
||||
*/
|
||||
@Query("SELECT s FROM SubscriptionEntity s WHERE s.status = 'TRIALING' AND s.trialEndDate BETWEEN :now AND :endDate ORDER BY s.trialEndDate ASC")
|
||||
List<SubscriptionEntity> findTrialsEndingBetween(@Param("now") LocalDateTime now, @Param("endDate") LocalDateTime endDate);
|
||||
|
||||
/**
|
||||
* Trouve les abonnements avec facturation dans la période spécifiée
|
||||
*/
|
||||
@Query("SELECT s FROM SubscriptionEntity s WHERE s.nextBillingDate BETWEEN :startDate AND :endDate ORDER BY s.nextBillingDate ASC")
|
||||
List<SubscriptionEntity> findSubscriptionsForBillingBetween(@Param("startDate") LocalDateTime startDate, @Param("endDate") LocalDateTime endDate);
|
||||
|
||||
/**
|
||||
* Trouve les abonnements créés dans une période
|
||||
*/
|
||||
List<SubscriptionEntity> findByCreatedAtBetweenOrderByCreatedAtDesc(LocalDateTime startDate, LocalDateTime endDate);
|
||||
|
||||
/**
|
||||
* Trouve les abonnements expirés (période courante terminée)
|
||||
*/
|
||||
@Query("SELECT s FROM SubscriptionEntity s WHERE s.currentPeriodEnd < :now AND s.status NOT IN ('CANCELED', 'UNPAID') ORDER BY s.currentPeriodEnd ASC")
|
||||
List<SubscriptionEntity> findExpiredSubscriptions(@Param("now") LocalDateTime now);
|
||||
|
||||
// ===== RECHERCHES PAR CYCLE DE FACTURATION =====
|
||||
|
||||
/**
|
||||
* Trouve les abonnements mensuels
|
||||
*/
|
||||
@Query("SELECT s FROM SubscriptionEntity s WHERE s.billingCycle = 'MONTHLY' ORDER BY s.createdAt DESC")
|
||||
List<SubscriptionEntity> findMonthlySubscriptions();
|
||||
|
||||
/**
|
||||
* Trouve les abonnements annuels
|
||||
*/
|
||||
@Query("SELECT s FROM SubscriptionEntity s WHERE s.billingCycle = 'YEARLY' ORDER BY s.createdAt DESC")
|
||||
List<SubscriptionEntity> findYearlySubscriptions();
|
||||
|
||||
// ===== RECHERCHES POUR ANNULATION =====
|
||||
|
||||
/**
|
||||
* Trouve les abonnements marqués pour annulation en fin de période
|
||||
*/
|
||||
@Query("SELECT s FROM SubscriptionEntity s WHERE s.cancelAtPeriodEnd = true ORDER BY s.currentPeriodEnd ASC")
|
||||
List<SubscriptionEntity> findSubscriptionsToCancel();
|
||||
|
||||
/**
|
||||
* Trouve les abonnements à annuler dans la période spécifiée
|
||||
*/
|
||||
@Query("SELECT s FROM SubscriptionEntity s WHERE s.cancelAtPeriodEnd = true AND s.currentPeriodEnd BETWEEN :startDate AND :endDate ORDER BY s.currentPeriodEnd ASC")
|
||||
List<SubscriptionEntity> findSubscriptionsToCancelBetween(@Param("startDate") LocalDateTime startDate, @Param("endDate") LocalDateTime endDate);
|
||||
|
||||
// ===== STATISTIQUES ET MÉTRIQUES =====
|
||||
|
||||
/**
|
||||
* Compte les abonnements par statut
|
||||
*/
|
||||
long countByStatus(SubscriptionStatusEntity status);
|
||||
|
||||
/**
|
||||
* Compte les abonnements par cycle de facturation
|
||||
*/
|
||||
@Query("SELECT COUNT(s) FROM SubscriptionEntity s WHERE s.billingCycle = :billingCycle")
|
||||
long countByBillingCycle(@Param("billingCycle") String billingCycle);
|
||||
|
||||
/**
|
||||
* Compte les nouveaux abonnements dans une période
|
||||
*/
|
||||
long countByCreatedAtBetween(LocalDateTime startDate, LocalDateTime endDate);
|
||||
|
||||
/**
|
||||
* Calcul du chiffre d'affaires mensuel récurrent (MRR)
|
||||
*/
|
||||
@Query("""
|
||||
SELECT SUM(
|
||||
CASE
|
||||
WHEN s.billingCycle = 'MONTHLY' THEN
|
||||
(SELECT p.monthlyPrice FROM PlanEntity p WHERE p.stripePriceIdMonthly = s.stripePriceId
|
||||
OR p.stripePriceIdYearly = s.stripePriceId)
|
||||
WHEN s.billingCycle = 'YEARLY' THEN
|
||||
(SELECT p.yearlyPrice FROM PlanEntity p WHERE p.stripePriceIdMonthly = s.stripePriceId
|
||||
OR p.stripePriceIdYearly = s.stripePriceId) / 12
|
||||
ELSE 0
|
||||
END
|
||||
)
|
||||
FROM SubscriptionEntity s
|
||||
WHERE s.status = 'ACTIVE'
|
||||
""")
|
||||
Double calculateMonthlyRecurringRevenue();
|
||||
|
||||
/**
|
||||
* Calcul du chiffre d'affaires annuel récurrent (ARR)
|
||||
*/
|
||||
@Query("""
|
||||
SELECT SUM(
|
||||
CASE
|
||||
WHEN s.billingCycle = 'MONTHLY' THEN
|
||||
(SELECT p.monthlyPrice FROM PlanEntity p WHERE p.stripePriceIdMonthly = s.stripePriceId
|
||||
OR p.stripePriceIdYearly = s.stripePriceId) * 12
|
||||
WHEN s.billingCycle = 'YEARLY' THEN
|
||||
(SELECT p.yearlyPrice FROM PlanEntity p WHERE p.stripePriceIdMonthly = s.stripePriceId
|
||||
OR p.stripePriceIdYearly = s.stripePriceId)
|
||||
ELSE 0
|
||||
END
|
||||
)
|
||||
FROM SubscriptionEntity s
|
||||
WHERE s.status = 'ACTIVE'
|
||||
""")
|
||||
Double calculateAnnualRecurringRevenue();
|
||||
|
||||
// ===== OPÉRATIONS DE MAINTENANCE =====
|
||||
|
||||
/**
|
||||
* Met à jour le statut des abonnements expirés
|
||||
*/
|
||||
@Modifying
|
||||
@Query("UPDATE SubscriptionEntity s SET s.status = 'CANCELED' WHERE s.currentPeriodEnd < :now AND s.cancelAtPeriodEnd = true")
|
||||
int cancelExpiredSubscriptions(@Param("now") LocalDateTime now);
|
||||
|
||||
/**
|
||||
* Met à jour le statut des essais expirés
|
||||
*/
|
||||
@Modifying
|
||||
@Query("UPDATE SubscriptionEntity s SET s.status = 'INCOMPLETE_EXPIRED' WHERE s.status = 'TRIALING' AND s.trialEndDate < :now")
|
||||
int expireTrialSubscriptions(@Param("now") LocalDateTime now);
|
||||
|
||||
/**
|
||||
* Supprime les anciens abonnements annulés
|
||||
*/
|
||||
@Modifying
|
||||
@Query("DELETE FROM SubscriptionEntity s WHERE s.status = 'CANCELED' AND s.modifiedDate < :cutoffDate")
|
||||
int deleteOldCanceledSubscriptions(@Param("cutoffDate") java.time.Instant cutoffDate);
|
||||
|
||||
// ===== RECHERCHES AVANCÉES =====
|
||||
|
||||
/**
|
||||
* Trouve les abonnements par multiple critères
|
||||
*/
|
||||
@Query("""
|
||||
SELECT s FROM SubscriptionEntity s
|
||||
WHERE (:status IS NULL OR s.status = :status)
|
||||
AND (:billingCycle IS NULL OR s.billingCycle = :billingCycle)
|
||||
AND (:customerId IS NULL OR s.stripeCustomerId = :customerId)
|
||||
ORDER BY s.createdAt DESC
|
||||
""")
|
||||
List<SubscriptionEntity> findByMultipleCriteria(
|
||||
@Param("status") SubscriptionStatusEntity status,
|
||||
@Param("billingCycle") String billingCycle,
|
||||
@Param("customerId") String customerId
|
||||
);
|
||||
|
||||
/**
|
||||
* Recherche full-text dans les abonnements (ID client, subscription ID)
|
||||
*/
|
||||
@Query("""
|
||||
SELECT s FROM SubscriptionEntity s
|
||||
WHERE s.stripeSubscriptionId LIKE %:searchTerm%
|
||||
OR s.stripeCustomerId LIKE %:searchTerm%
|
||||
ORDER BY s.createdAt DESC
|
||||
""")
|
||||
List<SubscriptionEntity> searchSubscriptions(@Param("searchTerm") String searchTerm);
|
||||
|
||||
/**
|
||||
* Trouve les top clients par nombre d'abonnements
|
||||
*/
|
||||
@Query("""
|
||||
SELECT s.stripeCustomerId, COUNT(s) as subscriptionCount
|
||||
FROM SubscriptionEntity s
|
||||
WHERE s.status = 'ACTIVE'
|
||||
GROUP BY s.stripeCustomerId
|
||||
ORDER BY subscriptionCount DESC
|
||||
""")
|
||||
List<Object[]> findTopCustomersBySubscriptionCount(Pageable pageable);
|
||||
}
|
||||
@ -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';
|
||||
6
pom.xml
6
pom.xml
@ -48,6 +48,7 @@
|
||||
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
|
||||
<org.projectlombok.version>1.18.36</org.projectlombok.version>
|
||||
<org.mapstruct.version>1.6.3</org.mapstruct.version>
|
||||
<stripe.version>24.16.0</stripe.version>
|
||||
</properties>
|
||||
|
||||
<dependencyManagement>
|
||||
@ -78,6 +79,11 @@
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<version>3.13.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.stripe</groupId>
|
||||
<artifactId>stripe-java</artifactId>
|
||||
<version>${stripe.version}</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user