Skip to main content

koprogo_api/domain/comptabilite/
owner_contribution.rs

1// Domain Entity: Owner Contribution
2//
3// Represents payments made BY owners TO the ACP (incoming money = revenue)
4// Complements Expense entity which represents payments made BY ACP TO suppliers (outgoing money = charges)
5//
6// Maps to PCMN classe 7 (Produits/Revenue)
7
8use chrono::{DateTime, Utc};
9use rust_decimal::Decimal;
10use serde::{Deserialize, Serialize};
11use uuid::Uuid;
12
13/// Type of owner contribution
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, utoipa::ToSchema)]
15#[serde(rename_all = "lowercase")]
16pub enum ContributionType {
17    /// Regular quarterly fees (appels de fonds ordinaires)
18    Regular,
19    /// Extraordinary fees for special works (appels de fonds extraordinaires)
20    Extraordinary,
21    /// Advance payment
22    Advance,
23    /// Adjustment (regularisation)
24    Adjustment,
25}
26
27/// Payment status for contributions
28#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, utoipa::ToSchema)]
29#[serde(rename_all = "lowercase")]
30pub enum ContributionPaymentStatus {
31    /// Not yet paid
32    Pending,
33    /// Fully paid
34    Paid,
35    /// Partially paid
36    Partial,
37    /// Cancelled
38    Cancelled,
39}
40
41/// Payment method for contributions
42#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, utoipa::ToSchema)]
43#[serde(rename_all = "snake_case")]
44pub enum ContributionPaymentMethod {
45    /// Bank transfer (virement)
46    BankTransfer,
47    /// Cash (espèces)
48    Cash,
49    /// Check (chèque)
50    Check,
51    /// Direct debit (domiciliation)
52    Domiciliation,
53}
54
55/// Traduction du moyen de paiement TECHNIQUE (module de paiement) vers le
56/// moyen COMPTABLE inscrit sur la quote-part.
57///
58/// Les deux enumerations ne se recouvrent pas : le module de paiement raisonne
59/// en canal d'encaissement (`Card`, `SepaDebit`, `BankTransfer`, `Cash`), la
60/// comptabilite de copropriete en mode de reglement (virement, especes,
61/// cheque, domiciliation). La correspondance est donc etablie ici, une seule
62/// fois, plutot que devinee a chaque appel.
63///
64/// Deux points assumes :
65///   - `Card` -> `BankTransfer`, faute de valeur « carte » cote comptable :
66///     un paiement par carte arrive sur le compte de l'ACP sous forme de
67///     virement du prestataire.
68///   - `SepaDebit` -> `Domiciliation`, qui est exactement la meme chose sous
69///     son nom belge.
70///
71/// Si la distinction devenait necessaire (rapprochement bancaire fin), c'est
72/// `ContributionPaymentMethod` qu'il faudrait etendre, pas cette conversion
73/// qu'il faudrait contourner. Le `match` est EXHAUSTIF sans bras `_` : ajouter
74/// un canal d'encaissement doit forcer a decider de sa traduction comptable,
75/// pas le laisser tomber silencieusement dans un defaut.
76impl From<crate::domain::entities::PaymentMethodType> for ContributionPaymentMethod {
77    fn from(value: crate::domain::entities::PaymentMethodType) -> Self {
78        use crate::domain::entities::PaymentMethodType;
79        match value {
80            PaymentMethodType::SepaDebit => ContributionPaymentMethod::Domiciliation,
81            PaymentMethodType::BankTransfer => ContributionPaymentMethod::BankTransfer,
82            PaymentMethodType::Cash => ContributionPaymentMethod::Cash,
83            PaymentMethodType::Card => ContributionPaymentMethod::BankTransfer,
84        }
85    }
86}
87
88/// Owner contribution (appel de fonds / cotisation)
89///
90/// Represents money paid BY owners TO the ACP (REVENUE - classe 7 PCMN)
91/// This is the opposite of Expense which represents money paid BY ACP TO suppliers
92#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
93pub struct OwnerContribution {
94    pub id: Uuid,
95
96    /// L'ACP à laquelle cette quote-part est due.
97    ///
98    /// Art. 3.86 § 3 : les apports des copropriétaires constituent le
99    /// patrimoine de l'ACP. Ce que doit un copropriétaire, il le doit à sa
100    /// copropriété, jamais au cabinet qui la gère au moment de l'appel.
101    /// Cf. ADR-0045.
102    pub acp_id: Uuid,
103
104    /// Le syndic qui a émis la quote-part, conservé comme trace d'auteur.
105    pub organization_id: Uuid,
106    pub owner_id: Uuid,
107    pub unit_id: Option<Uuid>,
108
109    // Financial details
110    pub description: String,
111    pub amount: Decimal,
112
113    // Accounting
114    /// PCMN code (classe 7 - Produits)
115    /// Examples: "7000" = regular fees, "7100" = extraordinary fees
116    pub account_code: Option<String>,
117
118    // Contribution details
119    pub contribution_type: ContributionType,
120
121    // Dates
122    pub contribution_date: DateTime<Utc>, // When due/requested
123    pub payment_date: Option<DateTime<Utc>>, // When actually paid
124
125    // Payment details
126    pub payment_method: Option<ContributionPaymentMethod>,
127    pub payment_reference: Option<String>,
128
129    // Status
130    pub payment_status: ContributionPaymentStatus,
131
132    // Link to collective call for funds (if generated from CallForFunds)
133    pub call_for_funds_id: Option<Uuid>,
134
135    // Metadata
136    pub notes: Option<String>,
137    pub created_at: DateTime<Utc>,
138    pub updated_at: DateTime<Utc>,
139    pub created_by: Option<Uuid>,
140}
141
142/// Domain-typed validation error for owner contributions (PCMN classe 7).
143///
144/// Pure domain type — no infra/application dependency (hexagonal purity).
145/// Précédent `JournalEntryError`/`ChargeDistributionError` : l'entité
146/// renvoie son erreur typée, l'application la mappe vers `AppError`
147/// (#433 / WP-A6 EXP-008) → 400 validation, jamais 500 Internal.
148#[derive(Debug, Clone, PartialEq)]
149pub enum OwnerContributionError {
150    /// Montant négatif (un revenu entrant ne peut être < 0).
151    NonPositiveAmount,
152    /// Description vide.
153    EmptyDescription,
154}
155
156impl std::fmt::Display for OwnerContributionError {
157    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
158        match self {
159            Self::NonPositiveAmount => write!(
160                f,
161                "Contribution amount must be positive (revenue = money coming IN)"
162            ),
163            Self::EmptyDescription => write!(f, "Description cannot be empty"),
164        }
165    }
166}
167
168impl std::error::Error for OwnerContributionError {}
169
170/// Bridge : use-cases/ports `Result<_, String>` inchangés pendant que
171/// l'entité est typée (cascade String→AppError = slice large différée,
172/// précédent WP-A3/A4/A5). Pur, std-only.
173impl From<OwnerContributionError> for String {
174    fn from(e: OwnerContributionError) -> String {
175        e.to_string()
176    }
177}
178
179impl OwnerContribution {
180    #[allow(clippy::too_many_arguments)]
181    pub fn new(
182        acp_id: Uuid,
183        organization_id: Uuid,
184        owner_id: Uuid,
185        unit_id: Option<Uuid>,
186        description: String,
187        amount: Decimal,
188        contribution_type: ContributionType,
189        contribution_date: DateTime<Utc>,
190        account_code: Option<String>,
191    ) -> Result<Self, OwnerContributionError> {
192        // Validate amount is positive (revenue = money coming IN)
193        if amount < Decimal::ZERO {
194            return Err(OwnerContributionError::NonPositiveAmount);
195        }
196
197        // Validate description
198        if description.trim().is_empty() {
199            return Err(OwnerContributionError::EmptyDescription);
200        }
201
202        Ok(Self {
203            id: Uuid::new_v4(),
204            acp_id,
205            organization_id,
206            owner_id,
207            unit_id,
208            description,
209            amount,
210            account_code,
211            contribution_type,
212            contribution_date,
213            payment_date: None,
214            payment_method: None,
215            payment_reference: None,
216            payment_status: ContributionPaymentStatus::Pending,
217            call_for_funds_id: None,
218            notes: None,
219            created_at: Utc::now(),
220            updated_at: Utc::now(),
221            created_by: None,
222        })
223    }
224
225    /// Mark contribution as paid
226    pub fn mark_as_paid(
227        &mut self,
228        payment_date: DateTime<Utc>,
229        payment_method: ContributionPaymentMethod,
230        payment_reference: Option<String>,
231    ) {
232        self.payment_date = Some(payment_date);
233        self.payment_method = Some(payment_method);
234        self.payment_reference = payment_reference;
235        self.payment_status = ContributionPaymentStatus::Paid;
236        self.updated_at = Utc::now();
237    }
238
239    /// Check if contribution is paid
240    pub fn is_paid(&self) -> bool {
241        self.payment_status == ContributionPaymentStatus::Paid
242    }
243
244    /// Check if contribution is overdue (not paid and past contribution_date)
245    pub fn is_overdue(&self) -> bool {
246        !self.is_paid() && Utc::now() > self.contribution_date
247    }
248}
249
250impl crate::domain::services::PieceDeGestion for OwnerContribution {
251    fn acp_id(&self) -> Uuid {
252        self.acp_id
253    }
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259
260    #[test]
261    fn test_create_contribution_success() {
262        let contrib = OwnerContribution::new(
263            Uuid::new_v4(), // acp_id
264            Uuid::new_v4(),
265            Uuid::new_v4(),
266            Some(Uuid::new_v4()),
267            "Appel de fonds Q1 2025".to_string(),
268            rust_decimal_macros::dec!(500),
269            ContributionType::Regular,
270            Utc::now(),
271            Some("7000".to_string()),
272        );
273
274        assert!(contrib.is_ok());
275        let contrib = contrib.unwrap();
276        assert_eq!(contrib.amount, rust_decimal_macros::dec!(500));
277        assert_eq!(contrib.payment_status, ContributionPaymentStatus::Pending);
278        assert!(!contrib.is_paid());
279    }
280
281    #[test]
282    fn test_create_contribution_negative_amount() {
283        let contrib = OwnerContribution::new(
284            Uuid::new_v4(), // acp_id
285            Uuid::new_v4(),
286            Uuid::new_v4(),
287            None,
288            "Test".to_string(),
289            rust_decimal_macros::dec!(-100), // Negative amount
290            ContributionType::Regular,
291            Utc::now(),
292            None,
293        );
294
295        assert!(matches!(
296            contrib.unwrap_err(),
297            OwnerContributionError::NonPositiveAmount
298        ));
299    }
300
301    #[test]
302    fn test_create_contribution_empty_description() {
303        let contrib = OwnerContribution::new(
304            Uuid::new_v4(), // acp_id
305            Uuid::new_v4(),
306            Uuid::new_v4(),
307            None,
308            "   ".to_string(), // Empty description
309            rust_decimal_macros::dec!(100),
310            ContributionType::Regular,
311            Utc::now(),
312            None,
313        );
314
315        assert!(matches!(
316            contrib.unwrap_err(),
317            OwnerContributionError::EmptyDescription
318        ));
319    }
320
321    #[test]
322    fn test_mark_as_paid() {
323        let mut contrib = OwnerContribution::new(
324            Uuid::new_v4(), // acp_id
325            Uuid::new_v4(),
326            Uuid::new_v4(),
327            None,
328            "Test payment".to_string(),
329            rust_decimal_macros::dec!(100),
330            ContributionType::Regular,
331            Utc::now(),
332            None,
333        )
334        .unwrap();
335
336        assert!(!contrib.is_paid());
337
338        contrib.mark_as_paid(
339            Utc::now(),
340            ContributionPaymentMethod::BankTransfer,
341            Some("REF-123".to_string()),
342        );
343
344        assert!(contrib.is_paid());
345        assert!(contrib.payment_date.is_some());
346        assert_eq!(
347            contrib.payment_method,
348            Some(ContributionPaymentMethod::BankTransfer)
349        );
350        assert_eq!(contrib.payment_reference, Some("REF-123".to_string()));
351    }
352
353    #[test]
354    fn test_is_overdue() {
355        let past_date = Utc::now() - chrono::Duration::days(30);
356
357        let contrib = OwnerContribution::new(
358            Uuid::new_v4(), // acp_id
359            Uuid::new_v4(),
360            Uuid::new_v4(),
361            None,
362            "Overdue contribution".to_string(),
363            rust_decimal_macros::dec!(100),
364            ContributionType::Regular,
365            past_date,
366            None,
367        )
368        .unwrap();
369
370        assert!(contrib.is_overdue());
371    }
372
373    // ------------------------------------------------------------------------
374    // 4 catégories #433/WP-A6 EXP-008 — erreur typée (CRITICAL.md #3).
375    // Entité déjà Decimal (PCMN classe 7) ; ce WP type l'erreur domaine.
376    // ------------------------------------------------------------------------
377
378    /// @happy — contribution nominale : montant Decimal exact conservé.
379    #[test]
380    fn happy_contribution_amount_decimal_exact() {
381        let c = OwnerContribution::new(
382            Uuid::new_v4(), // acp_id
383            Uuid::new_v4(),
384            Uuid::new_v4(),
385            None,
386            "Provision Q1".to_string(),
387            rust_decimal_macros::dec!(1234.56),
388            ContributionType::Regular,
389            Utc::now(),
390            None,
391        )
392        .unwrap();
393        assert_eq!(c.amount, rust_decimal_macros::dec!(1234.56));
394    }
395
396    /// @edge — montant exactement zéro accepté (revenu nul, borne incluse) ;
397    /// addition Decimal exacte (0.1+0.2=0.3, f64 échoue).
398    #[test]
399    fn edge_zero_amount_and_decimal_exactness() {
400        let zero = OwnerContribution::new(
401            Uuid::new_v4(), // acp_id
402            Uuid::new_v4(),
403            Uuid::new_v4(),
404            None,
405            "Régularisation nulle".to_string(),
406            Decimal::ZERO,
407            ContributionType::Regular,
408            Utc::now(),
409            None,
410        );
411        assert!(zero.is_ok());
412
413        let c = OwnerContribution::new(
414            Uuid::new_v4(), // acp_id
415            Uuid::new_v4(),
416            Uuid::new_v4(),
417            None,
418            "x".to_string(),
419            rust_decimal_macros::dec!(0.1) + rust_decimal_macros::dec!(0.2),
420            ContributionType::Regular,
421            Utc::now(),
422            None,
423        )
424        .unwrap();
425        assert_eq!(c.amount, rust_decimal_macros::dec!(0.3));
426    }
427
428    /// @negative — montant négatif & description vide rejetés (erreur typée).
429    #[test]
430    fn negative_amount_and_empty_description_rejected() {
431        assert!(matches!(
432            OwnerContribution::new(
433                Uuid::new_v4(), // acp_id
434                Uuid::new_v4(),
435                Uuid::new_v4(),
436                None,
437                "ok".to_string(),
438                rust_decimal_macros::dec!(-0.01),
439                ContributionType::Regular,
440                Utc::now(),
441                None,
442            )
443            .unwrap_err(),
444            OwnerContributionError::NonPositiveAmount
445        ));
446        assert!(matches!(
447            OwnerContribution::new(
448                Uuid::new_v4(), // acp_id
449                Uuid::new_v4(),
450                Uuid::new_v4(),
451                None,
452                "  ".to_string(),
453                rust_decimal_macros::dec!(10),
454                ContributionType::Regular,
455                Utc::now(),
456                None,
457            )
458            .unwrap_err(),
459            OwnerContributionError::EmptyDescription
460        ));
461    }
462
463    /// @security — un montant de revenu falsifié négatif (détournement
464    /// comptable PCMN classe 7) ne peut jamais être persisté.
465    #[test]
466    fn security_tampered_negative_revenue_rejected() {
467        let result = OwnerContribution::new(
468            Uuid::new_v4(), // acp_id
469            Uuid::new_v4(),
470            Uuid::new_v4(),
471            None,
472            "Faux avoir".to_string(),
473            rust_decimal_macros::dec!(-99999.99),
474            ContributionType::Regular,
475            Utc::now(),
476            None,
477        );
478        assert!(matches!(
479            result.unwrap_err(),
480            OwnerContributionError::NonPositiveAmount
481        ));
482    }
483}