feat(versement): nouveau module Versement (paiements rattachés à des objets)

- Entités : Versement, VersementObjet (lien polymorphique vers cotisation/adhesion/etc.)
- VersementRepository : requêtes par membre, org, période
- VersementResource : endpoints REST /api/versements
- VersementService : logique métier (validation, rattachement objets)
- Migration V27 : ajout numeroTelephone sur versements
This commit is contained in:
dahoud
2026-04-15 20:23:17 +00:00
parent 719d45e1fe
commit 5d028a10bf
6 changed files with 1070 additions and 0 deletions

View File

@@ -0,0 +1,171 @@
package dev.lions.unionflow.server.entity;
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.*;
import jakarta.validation.constraints.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicLong;
import lombok.*;
/**
* Versement — acte de régler une cotisation ou de déposer des fonds.
*
* <p>Un versement peut être effectué :
* <ul>
* <li>Via <b>Wave Mobile Money</b> : deep link natif, app Wave sur le même téléphone</li>
* <li>Manuellement : espèces, virement, chèque → statut EN_ATTENTE_VALIDATION</li>
* </ul>
*
* <p>Table DB : {@code paiements} (nom hérité, conservé pour compatibilité Flyway).
*
* @author UnionFlow Team
* @version 4.0
* @since 2026-04-13
*/
@Entity
@Table(name = "paiements", indexes = {
@Index(name = "idx_paiement_numero_reference", columnList = "numero_reference", unique = true),
@Index(name = "idx_paiement_membre", columnList = "membre_id"),
@Index(name = "idx_paiement_statut", columnList = "statut_paiement"),
@Index(name = "idx_paiement_methode", columnList = "methode_paiement"),
@Index(name = "idx_paiement_date", columnList = "date_paiement")
})
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@EqualsAndHashCode(callSuper = true)
public class Versement extends BaseEntity {
private static final AtomicLong REFERENCE_COUNTER =
new AtomicLong(System.currentTimeMillis() % 1_000_000_000_000L);
// ── Identité ──────────────────────────────────────────────────────────────
/** Référence unique (ex: VRS-2026-XXXXXXXXXXXX) */
@NotBlank
@Column(name = "numero_reference", unique = true, nullable = false, length = 50)
private String numeroReference;
// ── Montant ───────────────────────────────────────────────────────────────
@NotNull
@DecimalMin(value = "0.0", message = "Le montant doit être positif")
@Digits(integer = 12, fraction = 2)
@Column(name = "montant", nullable = false, precision = 14, scale = 2)
private BigDecimal montant;
@NotBlank
@Pattern(regexp = "^[A-Z]{3}$", message = "Code devise ISO à 3 lettres requis")
@Column(name = "code_devise", nullable = false, length = 3)
private String codeDevise;
// ── Méthode & Statut ──────────────────────────────────────────────────────
/** WAVE | ESPECES | VIREMENT | CHEQUE | AUTRE */
@NotNull
@Column(name = "methode_paiement", nullable = false, length = 50)
private String methodePaiement;
/** EN_ATTENTE | EN_COURS | CONFIRME | ECHEC | EN_ATTENTE_VALIDATION | ANNULE */
@NotNull
@Builder.Default
@Column(name = "statut_paiement", nullable = false, length = 30)
private String statutPaiement = "EN_ATTENTE";
// ── Dates ─────────────────────────────────────────────────────────────────
@Column(name = "date_paiement")
private LocalDateTime datePaiement;
@Column(name = "date_validation")
private LocalDateTime dateValidation;
// ── Validation ────────────────────────────────────────────────────────────
@Column(name = "validateur", length = 255)
private String validateur;
// ── Traçabilité ───────────────────────────────────────────────────────────
/** ID transaction Wave (TCN...) ou référence chèque / bordereau */
@Column(name = "reference_externe", length = 500)
private String referenceExterne;
@Column(name = "url_preuve", length = 1000)
private String urlPreuve;
@Column(name = "commentaire", length = 1000)
private String commentaire;
@Column(name = "ip_address", length = 45)
private String ipAddress;
@Column(name = "user_agent", length = 500)
private String userAgent;
// ── Téléphone Wave ────────────────────────────────────────────────────────
/**
* Numéro de téléphone Wave utilisé pour ce versement.
* Pré-rempli depuis le profil du membre (même téléphone qu'UnionFlow),
* modifiable à l'étape "Récapitulatif" avant de tapper "Payer".
*/
@Column(name = "numero_telephone", length = 20)
private String numeroTelephone;
// ── Relations ─────────────────────────────────────────────────────────────
@NotNull
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "membre_id", nullable = false)
private Membre membre;
@JsonIgnore
@OneToMany(mappedBy = "versement", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
@Builder.Default
private List<VersementObjet> versementsObjets = new ArrayList<>();
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "transaction_wave_id")
private TransactionWave transactionWave;
// ── Méthodes métier ───────────────────────────────────────────────────────
/** Génère une référence unique : VRS-YYYY-XXXXXXXXXXXX */
public static String genererNumeroReference() {
return "VRS-"
+ LocalDateTime.now().getYear()
+ "-"
+ String.format("%012d", REFERENCE_COUNTER.incrementAndGet() % 1_000_000_000_000L);
}
/** Vrai si le versement est confirmé (Wave) ou validé (manuel) */
public boolean isConfirme() {
return "CONFIRME".equals(statutPaiement) || "VALIDE".equals(statutPaiement);
}
/** Vrai si le versement peut encore être modifié ou annulé */
public boolean peutEtreModifie() {
return !"CONFIRME".equals(statutPaiement)
&& !"VALIDE".equals(statutPaiement)
&& !"ANNULE".equals(statutPaiement);
}
@PrePersist
protected void onCreate() {
super.onCreate();
if (numeroReference == null || numeroReference.isBlank()) {
numeroReference = genererNumeroReference();
}
if (statutPaiement == null) {
statutPaiement = "EN_ATTENTE";
}
if (datePaiement == null) {
datePaiement = LocalDateTime.now();
}
}
}

View File

@@ -0,0 +1,82 @@
package dev.lions.unionflow.server.entity;
import jakarta.persistence.*;
import jakarta.validation.constraints.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.UUID;
import lombok.*;
/**
* Liaison polymorphique entre un versement et son objet cible.
*
* <p>Remplace les 4 tables dupliquées (paiements_cotisations, paiements_adhesions,
* paiements_evenements, paiements_aides) par une table unique utilisant le pattern
* {@code (typeObjetCible, objetCibleId)}.
*
* <p>Table DB : {@code paiements_objets} (nom hérité, conservé pour compatibilité Flyway).
*
* @author UnionFlow Team
* @version 4.0
* @since 2026-04-13
*/
@Entity
@Table(name = "paiements_objets", indexes = {
@Index(name = "idx_po_paiement", columnList = "paiement_id"),
@Index(name = "idx_po_objet", columnList = "type_objet_cible, objet_cible_id"),
@Index(name = "idx_po_type", columnList = "type_objet_cible")
}, uniqueConstraints = {
@UniqueConstraint(name = "uk_paiement_objet", columnNames = {
"paiement_id", "type_objet_cible", "objet_cible_id"
})
})
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@EqualsAndHashCode(callSuper = true)
public class VersementObjet extends BaseEntity {
/** Versement parent. */
@NotNull
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "paiement_id", nullable = false)
private Versement versement;
/**
* Type de l'objet cible (domaine {@code OBJET_PAIEMENT}).
* Valeurs : COTISATION | ADHESION | EVENEMENT | AIDE.
*/
@NotBlank
@Size(max = 50)
@Column(name = "type_objet_cible", nullable = false, length = 50)
private String typeObjetCible;
/** UUID de l'objet cible (cotisation, adhésion, inscription, aide). */
@NotNull
@Column(name = "objet_cible_id", nullable = false)
private UUID objetCibleId;
/** Montant affecté à cet objet cible. */
@NotNull
@DecimalMin(value = "0.0", message = "Le montant doit être positif")
@Digits(integer = 12, fraction = 2)
@Column(name = "montant_applique", nullable = false, precision = 14, scale = 2)
private BigDecimal montantApplique;
@Column(name = "date_application")
private LocalDateTime dateApplication;
@Size(max = 500)
@Column(name = "commentaire", length = 500)
private String commentaire;
@Override
@PrePersist
protected void onCreate() {
super.onCreate();
if (dateApplication == null) {
dateApplication = LocalDateTime.now();
}
}
}