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