use derive_builder::Builder;
use serde::{Deserialize, Serialize};
use crate::primitives::*;
pub use cala_types::{primitives::TransactionId, transaction::*};
use es_entity::*;
use super::TransactionError;
#[derive(EsEvent, Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(tag = "type", rename_all = "snake_case")]
#[es_event(id = "TransactionId", event_context = false)]
pub enum TransactionEvent {
Initialized {
values: TransactionValues,
},
Updated {
values: TransactionValues,
fields: Vec<String>,
},
}
#[derive(EsEntity, Builder)]
#[builder(pattern = "owned", build_fn(error = "EntityHydrationError"))]
pub struct Transaction {
pub id: TransactionId,
values: TransactionValues,
events: EntityEvents<TransactionEvent>,
}
impl Transaction {
pub fn id(&self) -> TransactionId {
self.values.id
}
pub fn journal_id(&self) -> JournalId {
self.values.journal_id
}
pub fn values(&self) -> &TransactionValues {
&self.values
}
pub fn into_values(self) -> TransactionValues {
self.values
}
pub fn created_at(&self) -> chrono::DateTime<chrono::Utc> {
self.events
.entity_first_persisted_at()
.expect("No persisted events")
}
pub fn effective(&self) -> chrono::NaiveDate {
self.values.effective
}
pub fn modified_at(&self) -> chrono::DateTime<chrono::Utc> {
self.events
.entity_last_modified_at()
.expect("Entity not persisted")
}
pub fn metadata<T: serde::de::DeserializeOwned>(&self) -> Result<Option<T>, serde_json::Error> {
match &self.values.metadata {
Some(metadata) => Ok(Some(serde_json::from_value(metadata.clone())?)),
None => Ok(None),
}
}
fn is_voided(&self) -> bool {
self.events.iter_all()
.any(|event| matches!(event, TransactionEvent::Updated { values, .. } if values.voided_by.is_some()))
}
pub(super) fn void(
&mut self,
new_tx_id: TransactionId,
entry_ids: Vec<EntryId>,
created_at: chrono::DateTime<chrono::Utc>,
) -> Result<NewTransaction, TransactionError> {
if self.is_voided() {
return Err(TransactionError::AlreadyVoided(self.id));
}
self.values.voided_by = Some(new_tx_id);
let fields = vec!["voided_by".to_string()];
self.events.push(TransactionEvent::Updated {
values: self.values.clone(),
fields,
});
let values = self.values();
let mut builder = NewTransaction::builder();
builder
.id(new_tx_id)
.tx_template_id(values.tx_template_id)
.void_of(values.id)
.entry_ids(entry_ids.into_iter().collect())
.effective(created_at.date_naive())
.journal_id(values.journal_id)
.correlation_id(values.correlation_id.clone())
.created_at(created_at);
if let Some(ref external_id) = values.external_id {
builder.external_id(external_id);
}
if let Some(ref description) = values.description {
builder.description(description);
}
if let Some(ref metadata) = values.metadata {
builder.metadata(metadata.clone());
}
let new_transaction = builder.build().expect("Couldn't build voided transaction");
Ok(new_transaction)
}
}
impl TryFromEvents<TransactionEvent> for Transaction {
fn try_from_events(
events: EntityEvents<TransactionEvent>,
) -> Result<Self, EntityHydrationError> {
let mut builder = TransactionBuilder::default();
for event in events.iter_all() {
match event {
TransactionEvent::Initialized { values } => {
builder = builder.id(values.id).values(values.clone());
}
TransactionEvent::Updated { values, .. } => {
builder = builder.id(values.id).values(values.clone());
}
}
}
builder.events(events).build()
}
}
#[derive(Builder, Debug)]
#[allow(dead_code)]
pub struct NewTransaction {
#[builder(setter(custom))]
pub(super) id: TransactionId,
pub(super) created_at: chrono::DateTime<chrono::Utc>,
#[builder(setter(into))]
pub(super) journal_id: JournalId,
#[builder(setter(into))]
pub(super) tx_template_id: TxTemplateId,
pub(super) effective: chrono::NaiveDate,
#[builder(setter(into), default)]
pub(super) correlation_id: String,
#[builder(setter(strip_option, into), default)]
pub(super) void_of: Option<TransactionId>,
#[builder(setter(strip_option, into), default)]
pub(super) external_id: Option<String>,
#[builder(setter(strip_option, into), default)]
pub(super) description: Option<String>,
#[builder(setter(into), default)]
pub(super) metadata: Option<serde_json::Value>,
pub(super) entry_ids: Vec<EntryId>,
}
impl NewTransaction {
pub fn builder() -> NewTransactionBuilder {
NewTransactionBuilder::default()
}
}
impl IntoEvents<TransactionEvent> for NewTransaction {
fn into_events(self) -> EntityEvents<TransactionEvent> {
EntityEvents::init(
self.id,
[TransactionEvent::Initialized {
values: TransactionValues {
id: self.id,
version: 1,
created_at: self.created_at,
modified_at: self.created_at,
journal_id: self.journal_id,
tx_template_id: self.tx_template_id,
effective: self.effective,
correlation_id: self.correlation_id,
external_id: self.external_id,
description: self.description,
metadata: self.metadata,
entry_ids: self.entry_ids,
void_of: self.void_of,
voided_by: None,
},
}],
)
}
}
impl NewTransactionBuilder {
pub fn id(&mut self, id: impl Into<TransactionId>) -> &mut Self {
self.id = Some(id.into());
if self.correlation_id.is_none() {
self.correlation_id = Some(self.id.unwrap().to_string());
}
self
}
}
#[cfg(test)]
mod tests {
use super::*;
fn transaction() -> Transaction {
let id = TransactionId::new();
let values = TransactionValues {
id,
version: 1,
created_at: chrono::Utc::now(),
modified_at: chrono::Utc::now(),
journal_id: JournalId::new(),
tx_template_id: TxTemplateId::new(),
entry_ids: vec![],
effective: chrono::Utc::now().date_naive(),
correlation_id: "correlation_id".to_string(),
external_id: Some("external_id".to_string()),
description: None,
voided_by: None,
void_of: None,
metadata: Some(serde_json::json!({
"tx": "metadata",
"test": true,
})),
};
let events = es_entity::EntityEvents::init(id, [TransactionEvent::Initialized { values }]);
Transaction::try_from_events(events).unwrap()
}
#[test]
fn it_builds() {
let id = uuid::Uuid::now_v7();
let new_transaction = NewTransaction::builder()
.id(id)
.created_at(chrono::Utc::now())
.journal_id(uuid::Uuid::now_v7())
.tx_template_id(uuid::Uuid::now_v7())
.entry_ids(vec![EntryId::new()])
.effective(chrono::NaiveDate::default())
.build()
.unwrap();
assert_eq!(id.to_string(), new_transaction.correlation_id);
assert!(new_transaction.external_id.is_none());
}
#[test]
fn fails_when_mandatory_fields_are_missing() {
let new_transaction = NewTransaction::builder().build();
assert!(new_transaction.is_err());
}
#[test]
fn accepts_metadata() {
use serde_json::json;
let new_transaction = NewTransaction::builder()
.id(uuid::Uuid::now_v7())
.created_at(chrono::Utc::now())
.journal_id(uuid::Uuid::now_v7())
.tx_template_id(uuid::Uuid::now_v7())
.effective(chrono::NaiveDate::default())
.metadata(json!({"foo": "bar"}))
.entry_ids(vec![EntryId::new()])
.build()
.unwrap();
assert_eq!(new_transaction.metadata, Some(json!({"foo": "bar"})));
}
#[test]
fn void_transaction() {
let mut transaction = transaction();
let new_tx_id = TransactionId::new();
let entry_ids = vec![EntryId::new()];
let created_at = chrono::Utc::now();
let new_tx = transaction.void(new_tx_id, entry_ids.clone(), created_at);
assert!(new_tx.is_ok());
assert!(transaction.is_voided());
let new_tx = new_tx.unwrap();
assert_eq!(new_tx.void_of, Some(transaction.id));
let new_tx = transaction.void(new_tx_id, entry_ids, created_at);
assert!(matches!(new_tx, Err(TransactionError::AlreadyVoided(_))));
}
}