use chrono::{DateTime, NaiveDateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BiTemporal<T> {
pub data: T,
pub version_id: Uuid,
#[serde(with = "crate::serde_timestamp::naive")]
pub valid_from: NaiveDateTime,
#[serde(default, with = "crate::serde_timestamp::naive::option")]
pub valid_to: Option<NaiveDateTime>,
#[serde(with = "crate::serde_timestamp::utc")]
pub recorded_at: DateTime<Utc>,
#[serde(default, with = "crate::serde_timestamp::utc::option")]
pub superseded_at: Option<DateTime<Utc>>,
pub recorded_by: String,
pub change_reason: Option<String>,
pub previous_version_id: Option<Uuid>,
pub change_type: TemporalChangeType,
}
impl<T> BiTemporal<T> {
pub fn new(data: T) -> Self {
let now = Utc::now();
Self {
data,
version_id: Uuid::new_v4(),
valid_from: now.naive_utc(),
valid_to: None,
recorded_at: now,
superseded_at: None,
recorded_by: String::new(),
change_reason: None,
previous_version_id: None,
change_type: TemporalChangeType::Original,
}
}
pub fn with_valid_time(mut self, from: NaiveDateTime, to: Option<NaiveDateTime>) -> Self {
self.valid_from = from;
self.valid_to = to;
self
}
pub fn valid_from(mut self, from: NaiveDateTime) -> Self {
self.valid_from = from;
self
}
pub fn valid_to(mut self, to: NaiveDateTime) -> Self {
self.valid_to = Some(to);
self
}
pub fn with_recorded_at(mut self, recorded_at: DateTime<Utc>) -> Self {
self.recorded_at = recorded_at;
self
}
pub fn with_recorded_by(mut self, recorded_by: &str) -> Self {
self.recorded_by = recorded_by.into();
self
}
pub fn with_change_reason(mut self, reason: &str) -> Self {
self.change_reason = Some(reason.into());
self
}
pub fn with_change_type(mut self, change_type: TemporalChangeType) -> Self {
self.change_type = change_type;
self
}
pub fn with_previous_version(mut self, previous_id: Uuid) -> Self {
self.previous_version_id = Some(previous_id);
self
}
pub fn is_currently_valid(&self) -> bool {
let now = Utc::now().naive_utc();
self.valid_from <= now && self.valid_to.is_none_or(|to| to > now)
}
pub fn is_current_version(&self) -> bool {
self.superseded_at.is_none()
}
pub fn was_valid_at(&self, at: NaiveDateTime) -> bool {
self.valid_from <= at && self.valid_to.is_none_or(|to| to > at)
}
pub fn was_current_at(&self, at: DateTime<Utc>) -> bool {
self.recorded_at <= at && self.superseded_at.is_none_or(|sup| sup > at)
}
pub fn supersede(&mut self, superseded_at: DateTime<Utc>) {
self.superseded_at = Some(superseded_at);
}
pub fn correct(&self, new_data: T, corrected_by: &str, reason: &str) -> Self
where
T: Clone,
{
let now = Utc::now();
Self {
data: new_data,
version_id: Uuid::new_v4(),
valid_from: self.valid_from,
valid_to: self.valid_to,
recorded_at: now,
superseded_at: None,
recorded_by: corrected_by.into(),
change_reason: Some(reason.into()),
previous_version_id: Some(self.version_id),
change_type: TemporalChangeType::Correction,
}
}
pub fn reverse(&self, reversed_by: &str, reason: &str) -> Self
where
T: Clone,
{
let now = Utc::now();
Self {
data: self.data.clone(),
version_id: Uuid::new_v4(),
valid_from: now.naive_utc(),
valid_to: None,
recorded_at: now,
superseded_at: None,
recorded_by: reversed_by.into(),
change_reason: Some(reason.into()),
previous_version_id: Some(self.version_id),
change_type: TemporalChangeType::Reversal,
}
}
pub fn inner(&self) -> &T {
&self.data
}
pub fn inner_mut(&mut self) -> &mut T {
&mut self.data
}
pub fn into_inner(self) -> T {
self.data
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum TemporalChangeType {
#[default]
Original,
Correction,
Reversal,
Adjustment,
Reclassification,
LatePosting,
}
impl TemporalChangeType {
pub fn is_correction(&self) -> bool {
matches!(self, Self::Correction | Self::Reversal)
}
pub fn description(&self) -> &'static str {
match self {
Self::Original => "Original posting",
Self::Correction => "Error correction",
Self::Reversal => "Reversal entry",
Self::Adjustment => "Period adjustment",
Self::Reclassification => "Account reclassification",
Self::LatePosting => "Late posting",
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TemporalQuery {
#[serde(default, with = "crate::serde_timestamp::naive::option")]
pub as_of_valid_time: Option<NaiveDateTime>,
#[serde(default, with = "crate::serde_timestamp::utc::option")]
pub as_of_system_time: Option<DateTime<Utc>>,
pub include_history: bool,
}
impl TemporalQuery {
pub fn current() -> Self {
Self::default()
}
pub fn as_of_valid(time: NaiveDateTime) -> Self {
Self {
as_of_valid_time: Some(time),
..Default::default()
}
}
pub fn as_of_system(time: DateTime<Utc>) -> Self {
Self {
as_of_system_time: Some(time),
..Default::default()
}
}
pub fn with_history() -> Self {
Self {
include_history: true,
..Default::default()
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TemporalVersionChain<T> {
pub entity_id: Uuid,
pub versions: Vec<BiTemporal<T>>,
}
impl<T> TemporalVersionChain<T> {
pub fn new(entity_id: Uuid, initial: BiTemporal<T>) -> Self {
Self {
entity_id,
versions: vec![initial],
}
}
pub fn current(&self) -> Option<&BiTemporal<T>> {
self.versions.iter().find(|v| v.is_current_version())
}
pub fn version_at(&self, at: DateTime<Utc>) -> Option<&BiTemporal<T>> {
self.versions.iter().find(|v| v.was_current_at(at))
}
pub fn add_version(&mut self, version: BiTemporal<T>) {
if let Some(current) = self.versions.iter_mut().find(|v| v.is_current_version()) {
current.supersede(version.recorded_at);
}
self.versions.push(version);
}
pub fn all_versions(&self) -> &[BiTemporal<T>] {
&self.versions
}
pub fn version_count(&self) -> usize {
self.versions.len()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TemporalAuditEntry {
pub entry_id: Uuid,
pub entity_id: Uuid,
pub entity_type: String,
pub version_id: Uuid,
pub action: TemporalAction,
#[serde(with = "crate::serde_timestamp::utc")]
pub timestamp: DateTime<Utc>,
pub user_id: String,
pub reason: Option<String>,
pub previous_value: Option<String>,
pub new_value: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TemporalAction {
Create,
Update,
Correct,
Reverse,
Delete,
Restore,
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
struct TestEntity {
name: String,
value: i32,
}
#[test]
fn test_bitemporal_creation() {
let entity = TestEntity {
name: "Test".into(),
value: 100,
};
let temporal = BiTemporal::new(entity).with_recorded_by("user001");
assert!(temporal.is_current_version());
assert!(temporal.is_currently_valid());
assert_eq!(temporal.inner().value, 100);
}
#[test]
fn test_bitemporal_correction() {
let original = TestEntity {
name: "Test".into(),
value: 100,
};
let temporal = BiTemporal::new(original).with_recorded_by("user001");
let corrected = TestEntity {
name: "Test".into(),
value: 150,
};
let correction = temporal.correct(corrected, "user002", "Amount was wrong");
assert_eq!(correction.change_type, TemporalChangeType::Correction);
assert_eq!(correction.previous_version_id, Some(temporal.version_id));
assert_eq!(correction.inner().value, 150);
}
#[test]
fn test_version_chain() {
let entity = TestEntity {
name: "Test".into(),
value: 100,
};
let v1 = BiTemporal::new(entity.clone()).with_recorded_by("user001");
let entity_id = Uuid::new_v4();
let mut chain = TemporalVersionChain::new(entity_id, v1);
let v2 = BiTemporal::new(TestEntity {
name: "Test".into(),
value: 200,
})
.with_recorded_by("user002")
.with_change_type(TemporalChangeType::Correction);
chain.add_version(v2);
assert_eq!(chain.version_count(), 2);
assert_eq!(chain.current().unwrap().inner().value, 200);
}
#[test]
fn test_temporal_change_type() {
assert!(TemporalChangeType::Correction.is_correction());
assert!(TemporalChangeType::Reversal.is_correction());
assert!(!TemporalChangeType::Original.is_correction());
}
}