Skip to main content

koprogo_api/application/dto/
dashboard_dto.rs

1// Application DTOs: Dashboard
2//
3// Data Transfer Objects for dashboard statistics and recent transactions
4
5use chrono::{DateTime, Utc};
6use rust_decimal::Decimal;
7use serde::{Deserialize, Serialize};
8use uuid::Uuid;
9
10/// Accountant dashboard statistics.
11///
12/// MONETARY: amounts use rust_decimal::Decimal (cf. ADR-0007).
13/// Percentages remain Decimal to preserve exactness on display.
14#[derive(Debug, Serialize, Deserialize)]
15pub struct AccountantDashboardStats {
16    /// Total expenses for current month
17    pub total_expenses_current_month: Decimal,
18
19    /// Total paid expenses
20    pub total_paid: Decimal,
21
22    /// Percentage of expenses paid (serialized as JSON number for frontend display)
23    #[serde(with = "rust_decimal::serde::float")]
24    pub paid_percentage: Decimal,
25
26    /// Total unpaid/pending expenses
27    pub total_pending: Decimal,
28
29    /// Percentage of expenses pending (serialized as JSON number for frontend display)
30    #[serde(with = "rust_decimal::serde::float")]
31    pub pending_percentage: Decimal,
32
33    /// Number of owners with overdue payments
34    pub owners_with_overdue: i64,
35}
36
37/// Transaction type for dashboard display
38///
39/// **Important**: Currently only displays expenses (payments made).
40/// For a complete ACP (Association de Copropriétaires) accounting view, we would need:
41/// - PaymentReceived: Appels de fonds paid by owners (classe 7 PCMN - Produits)
42/// - PaymentMade: Expenses paid to suppliers (classe 6 PCMN - Charges)
43///
44/// **TODO**: Implement owner contributions tracking (appels de fonds) to show incoming payments
45#[derive(Debug, Serialize, Deserialize)]
46#[serde(rename_all = "lowercase")]
47pub enum TransactionType {
48    /// Payment received from owner (appels de fonds) - NOT YET IMPLEMENTED
49    PaymentReceived,
50    /// Payment made to supplier (expenses)
51    PaymentMade,
52}
53
54/// Recent transaction for dashboard
55#[derive(Debug, Serialize, Deserialize)]
56pub struct RecentTransaction {
57    /// Transaction ID
58    pub id: Uuid,
59
60    /// Transaction type
61    pub transaction_type: TransactionType,
62
63    /// Transaction description
64    pub description: String,
65
66    /// Related entity (owner name, supplier, etc.)
67    pub related_entity: Option<String>,
68
69    /// Transaction amount (positive for received, negative for paid). Decimal exact.
70    pub amount: Decimal,
71
72    /// Transaction date
73    pub date: DateTime<Utc>,
74}
75
76#[cfg(test)]
77mod tests {
78    use super::*;
79    use rust_decimal_macros::dec;
80    use serde_json::Value;
81
82    fn sample(paid_pct: Decimal, pending_pct: Decimal) -> AccountantDashboardStats {
83        AccountantDashboardStats {
84            total_expenses_current_month: dec!(1000.00),
85            total_paid: dec!(425.50),
86            paid_percentage: paid_pct,
87            total_pending: dec!(574.50),
88            pending_percentage: pending_pct,
89            owners_with_overdue: 3,
90        }
91    }
92
93    // @happy — percentages serialize as JSON numbers (not strings)
94    #[test]
95    fn paid_and_pending_percentage_serialize_as_json_number() {
96        let json: Value = serde_json::to_value(sample(dec!(42.5), dec!(57.5))).unwrap();
97        assert!(
98            json["paid_percentage"].is_number(),
99            "paid_percentage must be JSON number, got {:?}",
100            json["paid_percentage"]
101        );
102        assert!(
103            json["pending_percentage"].is_number(),
104            "pending_percentage must be JSON number, got {:?}",
105            json["pending_percentage"]
106        );
107        assert_eq!(json["paid_percentage"].as_f64().unwrap(), 42.5);
108    }
109
110    // @edge — 0 and 100 boundaries
111    #[test]
112    fn percentage_zero_and_hundred_serialize_as_number() {
113        let json: Value = serde_json::to_value(sample(dec!(0), dec!(100))).unwrap();
114        assert_eq!(json["paid_percentage"].as_f64().unwrap(), 0.0);
115        assert_eq!(json["pending_percentage"].as_f64().unwrap(), 100.0);
116    }
117
118    // @security — anti-regression: monetary fields stay JSON strings (no f64 on money rule)
119    #[test]
120    fn monetary_fields_remain_json_strings() {
121        let json: Value = serde_json::to_value(sample(dec!(50), dec!(50))).unwrap();
122        for field in [
123            "total_expenses_current_month",
124            "total_paid",
125            "total_pending",
126        ] {
127            assert!(
128                json[field].is_string(),
129                "{} must stay JSON string (Decimal exact), got {:?}",
130                field,
131                json[field]
132            );
133        }
134    }
135
136    // @negative — round-trip stays consistent (no precision drift breaking the contract)
137    #[test]
138    fn percentage_roundtrip_preserves_value() {
139        let original = sample(dec!(33.33), dec!(66.67));
140        let json = serde_json::to_string(&original).unwrap();
141        let parsed: AccountantDashboardStats = serde_json::from_str(&json).unwrap();
142        assert_eq!(parsed.paid_percentage, dec!(33.33));
143        assert_eq!(parsed.pending_percentage, dec!(66.67));
144    }
145}