cala_ledger/transaction/
entity.rs

1use derive_builder::Builder;
2use serde::{Deserialize, Serialize};
3
4use crate::primitives::*;
5pub use cala_types::{primitives::TransactionId, transaction::*};
6use es_entity::*;
7
8use super::TransactionError;
9
10#[derive(EsEvent, Debug, Serialize, Deserialize)]
11#[serde(tag = "type", rename_all = "snake_case")]
12#[es_event(id = "TransactionId", event_context = false)]
13pub enum TransactionEvent {
14    #[cfg(feature = "import")]
15    Imported {
16        source: DataSource,
17        values: TransactionValues,
18    },
19    Initialized {
20        values: TransactionValues,
21    },
22    Updated {
23        values: TransactionValues,
24        fields: Vec<String>,
25    },
26}
27
28#[derive(EsEntity, Builder)]
29#[builder(pattern = "owned", build_fn(error = "EsEntityError"))]
30pub struct Transaction {
31    pub id: TransactionId,
32    values: TransactionValues,
33    events: EntityEvents<TransactionEvent>,
34}
35
36impl Transaction {
37    #[cfg(feature = "import")]
38    pub(super) fn import(source: DataSourceId, values: TransactionValues) -> Self {
39        let events = EntityEvents::init(
40            values.id,
41            [TransactionEvent::Imported {
42                source: DataSource::Remote { id: source },
43                values,
44            }],
45        );
46        Self::try_from_events(events).expect("Failed to build transaction from events")
47    }
48
49    pub fn id(&self) -> TransactionId {
50        self.values.id
51    }
52
53    pub fn journal_id(&self) -> JournalId {
54        self.values.journal_id
55    }
56
57    pub fn values(&self) -> &TransactionValues {
58        &self.values
59    }
60
61    pub fn into_values(self) -> TransactionValues {
62        self.values
63    }
64
65    pub fn created_at(&self) -> chrono::DateTime<chrono::Utc> {
66        self.events
67            .entity_first_persisted_at()
68            .expect("No persisted events")
69    }
70
71    pub fn effective(&self) -> chrono::NaiveDate {
72        self.values.effective
73    }
74
75    pub fn modified_at(&self) -> chrono::DateTime<chrono::Utc> {
76        self.events
77            .entity_last_modified_at()
78            .expect("Entity not persisted")
79    }
80
81    pub fn metadata<T: serde::de::DeserializeOwned>(&self) -> Result<Option<T>, serde_json::Error> {
82        match &self.values.metadata {
83            Some(metadata) => Ok(Some(serde_json::from_value(metadata.clone())?)),
84            None => Ok(None),
85        }
86    }
87
88    fn is_voided(&self) -> bool {
89        self.events.iter_all()
90            .any(|event| matches!(event, TransactionEvent::Updated { values, .. } if values.voided_by.is_some()))
91    }
92
93    pub(super) fn void(
94        &mut self,
95        new_tx_id: TransactionId,
96        entry_ids: Vec<EntryId>,
97        created_at: chrono::DateTime<chrono::Utc>,
98    ) -> Result<NewTransaction, TransactionError> {
99        if self.is_voided() {
100            return Err(TransactionError::AlreadyVoided(self.id));
101        }
102
103        self.values.voided_by = Some(new_tx_id);
104        let fields = vec!["voided_by".to_string()];
105
106        self.events.push(TransactionEvent::Updated {
107            values: self.values.clone(),
108            fields,
109        });
110
111        let values = self.values();
112
113        let mut builder = NewTransaction::builder();
114        builder
115            .id(new_tx_id)
116            .tx_template_id(values.tx_template_id)
117            .void_of(values.id)
118            .entry_ids(entry_ids.into_iter().collect())
119            .effective(chrono::Utc::now().date_naive())
120            .journal_id(values.journal_id)
121            .correlation_id(values.correlation_id.clone())
122            .created_at(created_at);
123
124        if let Some(ref external_id) = values.external_id {
125            builder.external_id(external_id);
126        }
127        if let Some(ref description) = values.description {
128            builder.description(description);
129        }
130        if let Some(ref metadata) = values.metadata {
131            builder.metadata(metadata.clone());
132        }
133        let new_transaction = builder.build().expect("Couldn't build voided transaction");
134        Ok(new_transaction)
135    }
136}
137
138impl TryFromEvents<TransactionEvent> for Transaction {
139    fn try_from_events(events: EntityEvents<TransactionEvent>) -> Result<Self, EsEntityError> {
140        let mut builder = TransactionBuilder::default();
141        for event in events.iter_all() {
142            match event {
143                #[cfg(feature = "import")]
144                TransactionEvent::Imported { source: _, values } => {
145                    builder = builder.id(values.id).values(values.clone());
146                }
147                TransactionEvent::Initialized { values } => {
148                    builder = builder.id(values.id).values(values.clone());
149                }
150                TransactionEvent::Updated { values, .. } => {
151                    builder = builder.id(values.id).values(values.clone());
152                }
153            }
154        }
155        builder.events(events).build()
156    }
157}
158
159#[derive(Builder, Debug)]
160#[allow(dead_code)]
161pub struct NewTransaction {
162    #[builder(setter(custom))]
163    pub(super) id: TransactionId,
164    pub(super) created_at: chrono::DateTime<chrono::Utc>,
165    #[builder(setter(into))]
166    pub(super) journal_id: JournalId,
167    #[builder(setter(into))]
168    pub(super) tx_template_id: TxTemplateId,
169    pub(super) effective: chrono::NaiveDate,
170    #[builder(setter(into), default)]
171    pub(super) correlation_id: String,
172    #[builder(setter(strip_option, into), default)]
173    pub(super) void_of: Option<TransactionId>,
174    #[builder(setter(strip_option, into), default)]
175    pub(super) external_id: Option<String>,
176    #[builder(setter(strip_option, into), default)]
177    pub(super) description: Option<String>,
178    #[builder(setter(into), default)]
179    pub(super) metadata: Option<serde_json::Value>,
180    pub(super) entry_ids: Vec<EntryId>,
181}
182
183impl NewTransaction {
184    pub fn builder() -> NewTransactionBuilder {
185        NewTransactionBuilder::default()
186    }
187
188    pub(super) fn data_source(&self) -> DataSource {
189        DataSource::Local
190    }
191}
192
193impl IntoEvents<TransactionEvent> for NewTransaction {
194    fn into_events(self) -> EntityEvents<TransactionEvent> {
195        EntityEvents::init(
196            self.id,
197            [TransactionEvent::Initialized {
198                values: TransactionValues {
199                    id: self.id,
200                    version: 1,
201                    created_at: self.created_at,
202                    modified_at: self.created_at,
203                    journal_id: self.journal_id,
204                    tx_template_id: self.tx_template_id,
205                    effective: self.effective,
206                    correlation_id: self.correlation_id,
207                    external_id: self.external_id,
208                    description: self.description,
209                    metadata: self.metadata,
210                    entry_ids: self.entry_ids,
211                    void_of: self.void_of,
212                    voided_by: None,
213                },
214            }],
215        )
216    }
217}
218
219impl NewTransactionBuilder {
220    pub fn id(&mut self, id: impl Into<TransactionId>) -> &mut Self {
221        self.id = Some(id.into());
222        if self.correlation_id.is_none() {
223            self.correlation_id = Some(self.id.unwrap().to_string());
224        }
225        self
226    }
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232
233    fn transaction() -> Transaction {
234        let id = TransactionId::new();
235        let values = TransactionValues {
236            id,
237            version: 1,
238            created_at: chrono::Utc::now(),
239            modified_at: chrono::Utc::now(),
240            journal_id: JournalId::new(),
241            tx_template_id: TxTemplateId::new(),
242            entry_ids: vec![],
243            effective: chrono::Utc::now().date_naive(),
244            correlation_id: "correlation_id".to_string(),
245            external_id: Some("external_id".to_string()),
246            description: None,
247            voided_by: None,
248            void_of: None,
249            metadata: Some(serde_json::json!({
250                "tx": "metadata",
251                "test": true,
252            })),
253        };
254
255        let events = es_entity::EntityEvents::init(id, [TransactionEvent::Initialized { values }]);
256        Transaction::try_from_events(events).unwrap()
257    }
258
259    #[test]
260    fn it_builds() {
261        let id = uuid::Uuid::now_v7();
262        let new_transaction = NewTransaction::builder()
263            .id(id)
264            .created_at(chrono::Utc::now())
265            .journal_id(uuid::Uuid::now_v7())
266            .tx_template_id(uuid::Uuid::now_v7())
267            .entry_ids(vec![EntryId::new()])
268            .effective(chrono::NaiveDate::default())
269            .build()
270            .unwrap();
271        assert_eq!(id.to_string(), new_transaction.correlation_id);
272        assert!(new_transaction.external_id.is_none());
273    }
274
275    #[test]
276    fn fails_when_mandatory_fields_are_missing() {
277        let new_transaction = NewTransaction::builder().build();
278        assert!(new_transaction.is_err());
279    }
280
281    #[test]
282    fn accepts_metadata() {
283        use serde_json::json;
284        let new_transaction = NewTransaction::builder()
285            .id(uuid::Uuid::now_v7())
286            .created_at(chrono::Utc::now())
287            .journal_id(uuid::Uuid::now_v7())
288            .tx_template_id(uuid::Uuid::now_v7())
289            .effective(chrono::NaiveDate::default())
290            .metadata(json!({"foo": "bar"}))
291            .entry_ids(vec![EntryId::new()])
292            .build()
293            .unwrap();
294        assert_eq!(new_transaction.metadata, Some(json!({"foo": "bar"})));
295    }
296
297    #[test]
298    fn void_transaction() {
299        let mut transaction = transaction();
300        let new_tx_id = TransactionId::new();
301        let entry_ids = vec![EntryId::new()];
302        let created_at = chrono::Utc::now();
303
304        let new_tx = transaction.void(new_tx_id, entry_ids.clone(), created_at);
305        assert!(new_tx.is_ok());
306        assert!(transaction.is_voided());
307
308        let new_tx = new_tx.unwrap();
309        assert_eq!(new_tx.void_of, Some(transaction.id));
310
311        let new_tx = transaction.void(new_tx_id, entry_ids, created_at);
312        assert!(matches!(new_tx, Err(TransactionError::AlreadyVoided(_))));
313    }
314}