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.LocalDate;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
@ -17,15 +18,24 @@ public class License {
|
|||||||
private UUID id;
|
private UUID id;
|
||||||
private String licenseKey;
|
private String licenseKey;
|
||||||
private LicenseType type;
|
private LicenseType type;
|
||||||
|
private LicenseStatus status;
|
||||||
private LocalDate startDate;
|
private LocalDate startDate;
|
||||||
private LocalDate expirationDate;
|
private LocalDate expirationDate;
|
||||||
private LocalDateTime issuedDate;
|
private LocalDateTime issuedDate;
|
||||||
private LocalDateTime expiryDate;
|
private LocalDateTime expiryDate;
|
||||||
|
private LocalDateTime gracePeriodEndDate;
|
||||||
private int maxUsers;
|
private int maxUsers;
|
||||||
|
private Set<String> featuresEnabled;
|
||||||
private boolean isActive;
|
private boolean isActive;
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
private LocalDateTime lastCheckedAt;
|
||||||
private Company company;
|
private Company company;
|
||||||
|
private Subscription subscription;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return true si la licence est expirée (date d'expiration dépassée)
|
||||||
|
*/
|
||||||
public boolean isExpired() {
|
public boolean isExpired() {
|
||||||
return expirationDate != null && expirationDate.isBefore(LocalDate.now());
|
return expirationDate != null && expirationDate.isBefore(LocalDate.now());
|
||||||
}
|
}
|
||||||
@ -34,21 +44,158 @@ public class License {
|
|||||||
return expiryDate;
|
return expiryDate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return true si la licence est active (pas suspendue, pas expirée)
|
||||||
|
*/
|
||||||
|
public boolean isActive() {
|
||||||
|
return status == LicenseStatus.ACTIVE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return true si la licence est valide (active ou en période de grâce)
|
||||||
|
*/
|
||||||
public boolean isValid() {
|
public boolean isValid() {
|
||||||
return isActive && !isExpired();
|
return isActive() || isInGracePeriod();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return true si la licence est en période de grâce
|
||||||
|
*/
|
||||||
|
public boolean isInGracePeriod() {
|
||||||
|
return status == LicenseStatus.GRACE_PERIOD
|
||||||
|
&& gracePeriodEndDate != null
|
||||||
|
&& LocalDateTime.now().isBefore(gracePeriodEndDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return true si la licence est suspendue
|
||||||
|
*/
|
||||||
|
public boolean isSuspended() {
|
||||||
|
return status == LicenseStatus.SUSPENDED;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return true si la licence est annulée
|
||||||
|
*/
|
||||||
|
public boolean isCancelled() {
|
||||||
|
return status == LicenseStatus.CANCELLED;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return true si un utilisateur peut être ajouté
|
||||||
|
*/
|
||||||
public boolean canAddUser(int currentUserCount) {
|
public boolean canAddUser(int currentUserCount) {
|
||||||
return !hasUserLimit() || currentUserCount < maxUsers;
|
return isValid() && (!hasUserLimit() || currentUserCount < maxUsers);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return true si ce type de licence a une limite d'utilisateurs
|
||||||
|
*/
|
||||||
public boolean hasUserLimit() {
|
public boolean hasUserLimit() {
|
||||||
return type != null && type.hasUserLimit();
|
return type != null && type.hasUserLimit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return le nombre de jours jusqu'à l'expiration
|
||||||
|
*/
|
||||||
public long getDaysUntilExpiration() {
|
public long getDaysUntilExpiration() {
|
||||||
return expirationDate != null ?
|
return expirationDate != null ?
|
||||||
java.time.temporal.ChronoUnit.DAYS.between(LocalDate.now(), expirationDate) :
|
java.time.temporal.ChronoUnit.DAYS.between(LocalDate.now(), expirationDate) :
|
||||||
Long.MAX_VALUE;
|
Long.MAX_VALUE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return le nombre de jours restants en période de grâce (0 si pas en période de grâce)
|
||||||
|
*/
|
||||||
|
public long getDaysRemainingInGracePeriod() {
|
||||||
|
if (!isInGracePeriod() || gracePeriodEndDate == null) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return java.time.temporal.ChronoUnit.DAYS.between(LocalDateTime.now(), gracePeriodEndDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return true si la fonctionnalité est activée pour cette licence
|
||||||
|
*/
|
||||||
|
public boolean hasFeature(String featureCode) {
|
||||||
|
return featuresEnabled != null && featuresEnabled.contains(featureCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Active une fonctionnalité
|
||||||
|
*/
|
||||||
|
public void enableFeature(String featureCode) {
|
||||||
|
if (featuresEnabled == null) {
|
||||||
|
featuresEnabled = new java.util.HashSet<>();
|
||||||
|
}
|
||||||
|
featuresEnabled.add(featureCode);
|
||||||
|
this.updatedAt = LocalDateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Désactive une fonctionnalité
|
||||||
|
*/
|
||||||
|
public void disableFeature(String featureCode) {
|
||||||
|
if (featuresEnabled != null) {
|
||||||
|
featuresEnabled.remove(featureCode);
|
||||||
|
this.updatedAt = LocalDateTime.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Met à jour le statut de la licence
|
||||||
|
*/
|
||||||
|
public void updateStatus(LicenseStatus newStatus) {
|
||||||
|
this.status = newStatus;
|
||||||
|
this.updatedAt = LocalDateTime.now();
|
||||||
|
|
||||||
|
// Si on sort de la période de grâce, on reset la date
|
||||||
|
if (newStatus != LicenseStatus.GRACE_PERIOD) {
|
||||||
|
this.gracePeriodEndDate = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Démarre la période de grâce
|
||||||
|
*/
|
||||||
|
public void startGracePeriod(int gracePeriodDays) {
|
||||||
|
this.status = LicenseStatus.GRACE_PERIOD;
|
||||||
|
this.gracePeriodEndDate = LocalDateTime.now().plusDays(gracePeriodDays);
|
||||||
|
this.updatedAt = LocalDateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suspend la licence
|
||||||
|
*/
|
||||||
|
public void suspend() {
|
||||||
|
this.status = LicenseStatus.SUSPENDED;
|
||||||
|
this.isActive = false;
|
||||||
|
this.updatedAt = LocalDateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Réactive la licence
|
||||||
|
*/
|
||||||
|
public void reactivate() {
|
||||||
|
this.status = LicenseStatus.ACTIVE;
|
||||||
|
this.isActive = true;
|
||||||
|
this.gracePeriodEndDate = null;
|
||||||
|
this.updatedAt = LocalDateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return true si la licence nécessite une attention immédiate
|
||||||
|
*/
|
||||||
|
public boolean requiresAttention() {
|
||||||
|
return isSuspended()
|
||||||
|
|| (isInGracePeriod() && getDaysRemainingInGracePeriod() <= 1)
|
||||||
|
|| (getDaysUntilExpiration() <= 7 && getDaysUntilExpiration() > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Met à jour la dernière vérification
|
||||||
|
*/
|
||||||
|
public void updateLastChecked() {
|
||||||
|
this.lastCheckedAt = LocalDateTime.now();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
package com.dh7789dev.xpeditis.dto.app;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
public enum LicenseType {
|
public enum LicenseType {
|
||||||
TRIAL(5, 30),
|
TRIAL(5, 30, "Trial", BigDecimal.ZERO, BigDecimal.ZERO),
|
||||||
BASIC(10, -1),
|
BASIC(10, -1, "Basic Plan", BigDecimal.valueOf(29.00), BigDecimal.valueOf(278.00)),
|
||||||
PREMIUM(50, -1),
|
PREMIUM(50, -1, "Premium Plan", BigDecimal.valueOf(79.00), BigDecimal.valueOf(758.00)),
|
||||||
ENTERPRISE(-1, -1);
|
ENTERPRISE(-1, -1, "Enterprise Plan", BigDecimal.valueOf(199.00), BigDecimal.valueOf(1908.00));
|
||||||
|
|
||||||
private final int maxUsers;
|
private final int maxUsers;
|
||||||
private final int durationDays;
|
private final int durationDays;
|
||||||
|
private final String description;
|
||||||
|
private final BigDecimal basePrice;
|
||||||
|
private final BigDecimal yearlyPrice;
|
||||||
|
|
||||||
LicenseType(int maxUsers, int durationDays) {
|
LicenseType(int maxUsers, int durationDays, String description, BigDecimal basePrice, BigDecimal yearlyPrice) {
|
||||||
this.maxUsers = maxUsers;
|
this.maxUsers = maxUsers;
|
||||||
this.durationDays = durationDays;
|
this.durationDays = durationDays;
|
||||||
|
this.description = description;
|
||||||
|
this.basePrice = basePrice;
|
||||||
|
this.yearlyPrice = yearlyPrice;
|
||||||
}
|
}
|
||||||
|
|
||||||
public int getMaxUsers() {
|
public int getMaxUsers() {
|
||||||
@ -29,4 +37,44 @@ public enum LicenseType {
|
|||||||
public boolean hasTimeLimit() {
|
public boolean hasTimeLimit() {
|
||||||
return durationDays > 0;
|
return durationDays > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getDescription() {
|
||||||
|
return description;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigDecimal getBasePrice() {
|
||||||
|
return basePrice;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigDecimal getYearlyPrice() {
|
||||||
|
return yearlyPrice;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return true si c'est un plan gratuit
|
||||||
|
*/
|
||||||
|
public boolean isFree() {
|
||||||
|
return this == TRIAL;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return true si c'est un plan payant
|
||||||
|
*/
|
||||||
|
public boolean isPaid() {
|
||||||
|
return !isFree();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return le pourcentage d'économies annuelles
|
||||||
|
*/
|
||||||
|
public BigDecimal getYearlySavingsPercentage() {
|
||||||
|
if (basePrice.compareTo(BigDecimal.ZERO) == 0 || yearlyPrice.compareTo(BigDecimal.ZERO) == 0) {
|
||||||
|
return BigDecimal.ZERO;
|
||||||
|
}
|
||||||
|
|
||||||
|
BigDecimal yearlyEquivalent = basePrice.multiply(BigDecimal.valueOf(12));
|
||||||
|
BigDecimal savings = yearlyEquivalent.subtract(yearlyPrice);
|
||||||
|
return savings.divide(yearlyEquivalent, 4, java.math.RoundingMode.HALF_UP)
|
||||||
|
.multiply(BigDecimal.valueOf(100));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -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.Company;
|
||||||
import com.dh7789dev.xpeditis.dto.app.License;
|
import com.dh7789dev.xpeditis.dto.app.License;
|
||||||
import com.dh7789dev.xpeditis.dto.app.LicenseType;
|
import com.dh7789dev.xpeditis.dto.app.LicenseType;
|
||||||
|
import com.dh7789dev.xpeditis.dto.app.LicenseStatus;
|
||||||
|
import com.dh7789dev.xpeditis.dto.app.PlanFeature;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@ -18,43 +24,176 @@ public class LicenseServiceImpl implements LicenseService {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean validateLicense(UUID companyId) {
|
public boolean validateLicense(UUID companyId) {
|
||||||
// TODO: Implement license validation logic
|
License license = getActiveLicense(companyId);
|
||||||
return true; // Temporary implementation
|
if (license == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mettre à jour la dernière vérification
|
||||||
|
license.updateLastChecked();
|
||||||
|
licenseRepository.save(license);
|
||||||
|
|
||||||
|
// Vérifier si la licence est valide
|
||||||
|
return license.isValid();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean canAddUser(UUID companyId) {
|
public boolean canAddUser(UUID companyId) {
|
||||||
// TODO: Implement user addition validation logic
|
License license = getActiveLicense(companyId);
|
||||||
return true; // Temporary implementation
|
if (license == null || !license.isValid()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Récupérer le nombre d'utilisateurs actuel (nécessite UserService)
|
||||||
|
int currentUserCount = 0; // Placeholder - à implémenter avec injection UserService
|
||||||
|
|
||||||
|
return license.canAddUser(currentUserCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@Transactional
|
||||||
public License createTrialLicense(Company company) {
|
public License createTrialLicense(Company company) {
|
||||||
// TODO: Implement trial license creation logic
|
// Vérifier si l'entreprise a déjà une licence active
|
||||||
throw new UnsupportedOperationException("Not implemented yet");
|
License existingLicense = licenseRepository.findActiveLicenseByCompanyId(company.getId()).orElse(null);
|
||||||
|
if (existingLicense != null && existingLicense.isValid()) {
|
||||||
|
throw new IllegalStateException("L'entreprise a déjà une licence active");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Créer les fonctionnalités de base pour le trial
|
||||||
|
Set<String> trialFeatures = Set.of(
|
||||||
|
PlanFeature.Features.BASIC_QUOTES,
|
||||||
|
PlanFeature.Features.DOCUMENT_TEMPLATES
|
||||||
|
);
|
||||||
|
|
||||||
|
// Créer la licence trial
|
||||||
|
License trialLicense = License.builder()
|
||||||
|
.id(UUID.randomUUID())
|
||||||
|
.licenseKey(generateLicenseKey("TRIAL"))
|
||||||
|
.type(LicenseType.TRIAL)
|
||||||
|
.status(LicenseStatus.ACTIVE)
|
||||||
|
.startDate(LocalDate.now())
|
||||||
|
.expirationDate(LocalDate.now().plusDays(LicenseType.TRIAL.getDurationDays()))
|
||||||
|
.maxUsers(LicenseType.TRIAL.getMaxUsers())
|
||||||
|
.featuresEnabled(trialFeatures)
|
||||||
|
.isActive(true)
|
||||||
|
.company(company)
|
||||||
|
.createdAt(LocalDateTime.now())
|
||||||
|
.updatedAt(LocalDateTime.now())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
return licenseRepository.save(trialLicense);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@Transactional
|
||||||
public License upgradeLicense(UUID companyId, LicenseType newType) {
|
public License upgradeLicense(UUID companyId, LicenseType newType) {
|
||||||
// TODO: Implement license upgrade logic
|
License currentLicense = getActiveLicense(companyId);
|
||||||
throw new UnsupportedOperationException("Not implemented yet");
|
if (currentLicense == null) {
|
||||||
|
throw new IllegalArgumentException("Aucune licence active trouvée pour cette entreprise");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier que c'est bien un upgrade
|
||||||
|
if (newType == currentLicense.getType()) {
|
||||||
|
throw new IllegalArgumentException("La licence est déjà de ce type");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Créer les nouvelles fonctionnalités selon le type
|
||||||
|
Set<String> newFeatures = getFeaturesForLicenseType(newType);
|
||||||
|
|
||||||
|
// Mettre à jour la licence existante
|
||||||
|
currentLicense.setType(newType);
|
||||||
|
currentLicense.setMaxUsers(newType.getMaxUsers());
|
||||||
|
currentLicense.setFeaturesEnabled(newFeatures);
|
||||||
|
currentLicense.setUpdatedAt(LocalDateTime.now());
|
||||||
|
|
||||||
|
// Pour un upgrade vers un plan payant, on supprime la date d'expiration
|
||||||
|
if (newType != LicenseType.TRIAL) {
|
||||||
|
currentLicense.setExpirationDate(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return licenseRepository.save(currentLicense);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@Transactional
|
||||||
public void deactivateLicense(UUID licenseId) {
|
public void deactivateLicense(UUID licenseId) {
|
||||||
// TODO: Implement license deactivation logic
|
License license = licenseRepository.findById(licenseId)
|
||||||
throw new UnsupportedOperationException("Not implemented yet");
|
.orElseThrow(() -> new IllegalArgumentException("Licence non trouvée"));
|
||||||
|
|
||||||
|
license.suspend();
|
||||||
|
licenseRepository.save(license);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public License getActiveLicense(UUID companyId) {
|
public License getActiveLicense(UUID companyId) {
|
||||||
// TODO: Implement active license retrieval logic
|
return licenseRepository.findActiveLicenseByCompanyId(companyId)
|
||||||
throw new UnsupportedOperationException("Not implemented yet");
|
.orElse(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public long getDaysUntilExpiration(UUID companyId) {
|
public long getDaysUntilExpiration(UUID companyId) {
|
||||||
// TODO: Implement days until expiration calculation logic
|
License license = getActiveLicense(companyId);
|
||||||
return Long.MAX_VALUE; // Temporary implementation
|
if (license == null) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return license.getDaysUntilExpiration();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Génère une clé de licence unique
|
||||||
|
*/
|
||||||
|
private String generateLicenseKey(String prefix) {
|
||||||
|
return prefix + "-" + UUID.randomUUID().toString().substring(0, 8).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne les fonctionnalités pour un type de licence donné
|
||||||
|
*/
|
||||||
|
private Set<String> getFeaturesForLicenseType(LicenseType type) {
|
||||||
|
switch (type) {
|
||||||
|
case TRIAL:
|
||||||
|
return Set.of(
|
||||||
|
PlanFeature.Features.BASIC_QUOTES,
|
||||||
|
PlanFeature.Features.DOCUMENT_TEMPLATES
|
||||||
|
);
|
||||||
|
case BASIC:
|
||||||
|
return Set.of(
|
||||||
|
PlanFeature.Features.BASIC_QUOTES,
|
||||||
|
PlanFeature.Features.DOCUMENT_TEMPLATES,
|
||||||
|
PlanFeature.Features.MULTI_CURRENCY,
|
||||||
|
PlanFeature.Features.TEAM_COLLABORATION
|
||||||
|
);
|
||||||
|
case PREMIUM:
|
||||||
|
return Set.of(
|
||||||
|
PlanFeature.Features.BASIC_QUOTES,
|
||||||
|
PlanFeature.Features.DOCUMENT_TEMPLATES,
|
||||||
|
PlanFeature.Features.MULTI_CURRENCY,
|
||||||
|
PlanFeature.Features.TEAM_COLLABORATION,
|
||||||
|
PlanFeature.Features.ADVANCED_ANALYTICS,
|
||||||
|
PlanFeature.Features.BULK_IMPORT,
|
||||||
|
PlanFeature.Features.API_ACCESS,
|
||||||
|
PlanFeature.Features.PRIORITY_SUPPORT,
|
||||||
|
PlanFeature.Features.AUTOMATED_WORKFLOWS
|
||||||
|
);
|
||||||
|
case ENTERPRISE:
|
||||||
|
return Set.of(
|
||||||
|
PlanFeature.Features.BASIC_QUOTES,
|
||||||
|
PlanFeature.Features.DOCUMENT_TEMPLATES,
|
||||||
|
PlanFeature.Features.MULTI_CURRENCY,
|
||||||
|
PlanFeature.Features.TEAM_COLLABORATION,
|
||||||
|
PlanFeature.Features.ADVANCED_ANALYTICS,
|
||||||
|
PlanFeature.Features.BULK_IMPORT,
|
||||||
|
PlanFeature.Features.API_ACCESS,
|
||||||
|
PlanFeature.Features.PRIORITY_SUPPORT,
|
||||||
|
PlanFeature.Features.AUTOMATED_WORKFLOWS,
|
||||||
|
PlanFeature.Features.CUSTOM_BRANDING,
|
||||||
|
PlanFeature.Features.CUSTOM_INTEGRATIONS,
|
||||||
|
PlanFeature.Features.AUDIT_LOGS,
|
||||||
|
PlanFeature.Features.SLA_GUARANTEE,
|
||||||
|
PlanFeature.Features.DEDICATED_SUPPORT
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return Set.of();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
<scope>test</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Stripe SDK -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.stripe</groupId>
|
||||||
|
<artifactId>stripe-java</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Thymeleaf for email templates -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-thymeleaf</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<!-- JWT -->
|
<!-- JWT -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>io.jsonwebtoken</groupId>
|
<groupId>io.jsonwebtoken</groupId>
|
||||||
|
|||||||
@ -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>
|
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
|
||||||
<org.projectlombok.version>1.18.36</org.projectlombok.version>
|
<org.projectlombok.version>1.18.36</org.projectlombok.version>
|
||||||
<org.mapstruct.version>1.6.3</org.mapstruct.version>
|
<org.mapstruct.version>1.6.3</org.mapstruct.version>
|
||||||
|
<stripe.version>24.16.0</stripe.version>
|
||||||
</properties>
|
</properties>
|
||||||
|
|
||||||
<dependencyManagement>
|
<dependencyManagement>
|
||||||
@ -78,6 +79,11 @@
|
|||||||
<artifactId>maven-compiler-plugin</artifactId>
|
<artifactId>maven-compiler-plugin</artifactId>
|
||||||
<version>3.13.0</version>
|
<version>3.13.0</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.stripe</groupId>
|
||||||
|
<artifactId>stripe-java</artifactId>
|
||||||
|
<version>${stripe.version}</version>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
</dependencyManagement>
|
</dependencyManagement>
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user