Skip to main content

datasynth_core/models/
temporal.rs

1//! Bi-temporal data model support for audit trail requirements.
2//!
3//! This module provides temporal wrappers and types for tracking both:
4//! - Business validity (when the fact is true in the real world)
5//! - System recording (when we recorded this in the system)
6//!
7//! This is critical for audit trails and point-in-time queries.
8
9use chrono::{DateTime, NaiveDateTime, Utc};
10use serde::{Deserialize, Serialize};
11use uuid::Uuid;
12
13/// Bi-temporal wrapper for any auditable entity.
14///
15/// Provides two temporal dimensions:
16/// - **Business time**: When the fact was/is true in the real world
17/// - **System time**: When the fact was recorded in the system
18///
19/// # Example
20///
21/// ```ignore
22/// use datasynth_core::models::BiTemporal;
23///
24/// // A journal entry that was valid from Jan 1 to Jan 15
25/// // but was recorded on Jan 5 and corrected on Jan 16
26/// let entry = BiTemporal::new(journal_entry)
27///     .with_valid_time(jan_1, Some(jan_15))
28///     .with_recorded_by("user001")
29///     .with_change_reason("Initial posting");
30/// ```
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct BiTemporal<T> {
33    /// The wrapped data
34    pub data: T,
35
36    /// Unique version ID for this temporal record
37    pub version_id: Uuid,
38
39    // === Business Time (Valid Time) ===
40    /// When this fact became true in the business world
41    #[serde(with = "crate::serde_timestamp::naive")]
42    pub valid_from: NaiveDateTime,
43    /// When this fact stopped being true (None = still current)
44    #[serde(default, with = "crate::serde_timestamp::naive::option")]
45    pub valid_to: Option<NaiveDateTime>,
46
47    // === System Time (Transaction Time) ===
48    /// When this record was created in the system
49    #[serde(with = "crate::serde_timestamp::utc")]
50    pub recorded_at: DateTime<Utc>,
51    /// When this record was superseded by a newer version (None = current version)
52    #[serde(default, with = "crate::serde_timestamp::utc::option")]
53    pub superseded_at: Option<DateTime<Utc>>,
54
55    // === Audit Metadata ===
56    /// User/system that recorded this version
57    pub recorded_by: String,
58    /// Reason for change (for corrections/adjustments)
59    pub change_reason: Option<String>,
60    /// Previous version ID (for version chain)
61    pub previous_version_id: Option<Uuid>,
62    /// Change type classification
63    pub change_type: TemporalChangeType,
64}
65
66impl<T> BiTemporal<T> {
67    /// Create a new bi-temporal record with current timestamps.
68    pub fn new(data: T) -> Self {
69        let now = Utc::now();
70        Self {
71            data,
72            version_id: Uuid::new_v4(),
73            valid_from: now.naive_utc(),
74            valid_to: None,
75            recorded_at: now,
76            superseded_at: None,
77            recorded_by: String::new(),
78            change_reason: None,
79            previous_version_id: None,
80            change_type: TemporalChangeType::Original,
81        }
82    }
83
84    /// Set the valid time range.
85    pub fn with_valid_time(mut self, from: NaiveDateTime, to: Option<NaiveDateTime>) -> Self {
86        self.valid_from = from;
87        self.valid_to = to;
88        self
89    }
90
91    /// Set valid_from only.
92    pub fn valid_from(mut self, from: NaiveDateTime) -> Self {
93        self.valid_from = from;
94        self
95    }
96
97    /// Set valid_to only.
98    pub fn valid_to(mut self, to: NaiveDateTime) -> Self {
99        self.valid_to = Some(to);
100        self
101    }
102
103    /// Set the recorded_at timestamp.
104    pub fn with_recorded_at(mut self, recorded_at: DateTime<Utc>) -> Self {
105        self.recorded_at = recorded_at;
106        self
107    }
108
109    /// Set who recorded this version.
110    pub fn with_recorded_by(mut self, recorded_by: &str) -> Self {
111        self.recorded_by = recorded_by.into();
112        self
113    }
114
115    /// Set the change reason.
116    pub fn with_change_reason(mut self, reason: &str) -> Self {
117        self.change_reason = Some(reason.into());
118        self
119    }
120
121    /// Set the change type.
122    pub fn with_change_type(mut self, change_type: TemporalChangeType) -> Self {
123        self.change_type = change_type;
124        self
125    }
126
127    /// Link to a previous version.
128    pub fn with_previous_version(mut self, previous_id: Uuid) -> Self {
129        self.previous_version_id = Some(previous_id);
130        self
131    }
132
133    /// Check if this record is currently valid (business time).
134    pub fn is_currently_valid(&self) -> bool {
135        let now = Utc::now().naive_utc();
136        self.valid_from <= now && self.valid_to.is_none_or(|to| to > now)
137    }
138
139    /// Check if this is the current version (system time).
140    pub fn is_current_version(&self) -> bool {
141        self.superseded_at.is_none()
142    }
143
144    /// Check if this record was valid at a specific business time.
145    pub fn was_valid_at(&self, at: NaiveDateTime) -> bool {
146        self.valid_from <= at && self.valid_to.is_none_or(|to| to > at)
147    }
148
149    /// Check if this version was the current version at a specific system time.
150    pub fn was_current_at(&self, at: DateTime<Utc>) -> bool {
151        self.recorded_at <= at && self.superseded_at.is_none_or(|sup| sup > at)
152    }
153
154    /// Supersede this record with a new version.
155    pub fn supersede(&mut self, superseded_at: DateTime<Utc>) {
156        self.superseded_at = Some(superseded_at);
157    }
158
159    /// Create a correction of this record.
160    pub fn correct(&self, new_data: T, corrected_by: &str, reason: &str) -> Self
161    where
162        T: Clone,
163    {
164        let now = Utc::now();
165        Self {
166            data: new_data,
167            version_id: Uuid::new_v4(),
168            valid_from: self.valid_from,
169            valid_to: self.valid_to,
170            recorded_at: now,
171            superseded_at: None,
172            recorded_by: corrected_by.into(),
173            change_reason: Some(reason.into()),
174            previous_version_id: Some(self.version_id),
175            change_type: TemporalChangeType::Correction,
176        }
177    }
178
179    /// Create a reversal of this record.
180    pub fn reverse(&self, reversed_by: &str, reason: &str) -> Self
181    where
182        T: Clone,
183    {
184        let now = Utc::now();
185        Self {
186            data: self.data.clone(),
187            version_id: Uuid::new_v4(),
188            valid_from: now.naive_utc(),
189            valid_to: None,
190            recorded_at: now,
191            superseded_at: None,
192            recorded_by: reversed_by.into(),
193            change_reason: Some(reason.into()),
194            previous_version_id: Some(self.version_id),
195            change_type: TemporalChangeType::Reversal,
196        }
197    }
198
199    /// Get a reference to the underlying data.
200    pub fn inner(&self) -> &T {
201        &self.data
202    }
203
204    /// Get a mutable reference to the underlying data.
205    pub fn inner_mut(&mut self) -> &mut T {
206        &mut self.data
207    }
208
209    /// Consume and return the underlying data.
210    pub fn into_inner(self) -> T {
211        self.data
212    }
213}
214
215/// Type of temporal change.
216#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
217#[serde(rename_all = "snake_case")]
218pub enum TemporalChangeType {
219    /// Original record
220    #[default]
221    Original,
222    /// Correction of a previous record
223    Correction,
224    /// Reversal of a previous record
225    Reversal,
226    /// Adjustment (e.g., period-end adjustments)
227    Adjustment,
228    /// Reclassification
229    Reclassification,
230    /// Late posting (posted in subsequent period)
231    LatePosting,
232}
233
234impl TemporalChangeType {
235    /// Check if this is an error correction type.
236    pub fn is_correction(&self) -> bool {
237        matches!(self, Self::Correction | Self::Reversal)
238    }
239
240    /// Get a human-readable description.
241    pub fn description(&self) -> &'static str {
242        match self {
243            Self::Original => "Original posting",
244            Self::Correction => "Error correction",
245            Self::Reversal => "Reversal entry",
246            Self::Adjustment => "Period adjustment",
247            Self::Reclassification => "Account reclassification",
248            Self::LatePosting => "Late posting",
249        }
250    }
251}
252
253/// Temporal query parameters for point-in-time queries.
254#[derive(Debug, Clone, Default, Serialize, Deserialize)]
255pub struct TemporalQuery {
256    /// Query as of this business time (None = current)
257    #[serde(default, with = "crate::serde_timestamp::naive::option")]
258    pub as_of_valid_time: Option<NaiveDateTime>,
259    /// Query as of this system time (None = current)
260    #[serde(default, with = "crate::serde_timestamp::utc::option")]
261    pub as_of_system_time: Option<DateTime<Utc>>,
262    /// Include superseded versions
263    pub include_history: bool,
264}
265
266impl TemporalQuery {
267    /// Query for current data.
268    pub fn current() -> Self {
269        Self::default()
270    }
271
272    /// Query as of a specific business time.
273    pub fn as_of_valid(time: NaiveDateTime) -> Self {
274        Self {
275            as_of_valid_time: Some(time),
276            ..Default::default()
277        }
278    }
279
280    /// Query as of a specific system time.
281    pub fn as_of_system(time: DateTime<Utc>) -> Self {
282        Self {
283            as_of_system_time: Some(time),
284            ..Default::default()
285        }
286    }
287
288    /// Query with full history.
289    pub fn with_history() -> Self {
290        Self {
291            include_history: true,
292            ..Default::default()
293        }
294    }
295}
296
297/// Temporal version chain for tracking all versions of an entity.
298#[derive(Debug, Clone, Serialize, Deserialize)]
299pub struct TemporalVersionChain<T> {
300    /// Entity ID (stable across versions)
301    pub entity_id: Uuid,
302    /// All versions ordered by recorded_at
303    pub versions: Vec<BiTemporal<T>>,
304}
305
306impl<T> TemporalVersionChain<T> {
307    /// Create a new version chain with an initial version.
308    pub fn new(entity_id: Uuid, initial: BiTemporal<T>) -> Self {
309        Self {
310            entity_id,
311            versions: vec![initial],
312        }
313    }
314
315    /// Get the current version.
316    pub fn current(&self) -> Option<&BiTemporal<T>> {
317        self.versions.iter().find(|v| v.is_current_version())
318    }
319
320    /// Get the version that was current at a specific system time.
321    pub fn version_at(&self, at: DateTime<Utc>) -> Option<&BiTemporal<T>> {
322        self.versions.iter().find(|v| v.was_current_at(at))
323    }
324
325    /// Add a new version.
326    pub fn add_version(&mut self, version: BiTemporal<T>) {
327        // Supersede the current version
328        if let Some(current) = self.versions.iter_mut().find(|v| v.is_current_version()) {
329            current.supersede(version.recorded_at);
330        }
331        self.versions.push(version);
332    }
333
334    /// Get all versions.
335    pub fn all_versions(&self) -> &[BiTemporal<T>] {
336        &self.versions
337    }
338
339    /// Get the number of versions.
340    pub fn version_count(&self) -> usize {
341        self.versions.len()
342    }
343}
344
345/// Temporal audit trail entry.
346#[derive(Debug, Clone, Serialize, Deserialize)]
347pub struct TemporalAuditEntry {
348    /// Entry ID
349    pub entry_id: Uuid,
350    /// Entity ID being tracked
351    pub entity_id: Uuid,
352    /// Entity type
353    pub entity_type: String,
354    /// Version ID
355    pub version_id: Uuid,
356    /// Action performed
357    pub action: TemporalAction,
358    /// Timestamp
359    #[serde(with = "crate::serde_timestamp::utc")]
360    pub timestamp: DateTime<Utc>,
361    /// User who performed the action
362    pub user_id: String,
363    /// Reason for the action
364    pub reason: Option<String>,
365    /// Previous value (serialized)
366    pub previous_value: Option<String>,
367    /// New value (serialized)
368    pub new_value: Option<String>,
369}
370
371/// Actions tracked in temporal audit trail.
372#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
373#[serde(rename_all = "snake_case")]
374pub enum TemporalAction {
375    Create,
376    Update,
377    Correct,
378    Reverse,
379    Delete,
380    Restore,
381}
382
383#[cfg(test)]
384#[allow(clippy::unwrap_used)]
385mod tests {
386    use super::*;
387
388    #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
389    struct TestEntity {
390        name: String,
391        value: i32,
392    }
393
394    #[test]
395    fn test_bitemporal_creation() {
396        let entity = TestEntity {
397            name: "Test".into(),
398            value: 100,
399        };
400        let temporal = BiTemporal::new(entity).with_recorded_by("user001");
401
402        assert!(temporal.is_current_version());
403        assert!(temporal.is_currently_valid());
404        assert_eq!(temporal.inner().value, 100);
405    }
406
407    #[test]
408    fn test_bitemporal_correction() {
409        let original = TestEntity {
410            name: "Test".into(),
411            value: 100,
412        };
413        let temporal = BiTemporal::new(original).with_recorded_by("user001");
414
415        let corrected = TestEntity {
416            name: "Test".into(),
417            value: 150,
418        };
419        let correction = temporal.correct(corrected, "user002", "Amount was wrong");
420
421        assert_eq!(correction.change_type, TemporalChangeType::Correction);
422        assert_eq!(correction.previous_version_id, Some(temporal.version_id));
423        assert_eq!(correction.inner().value, 150);
424    }
425
426    #[test]
427    fn test_version_chain() {
428        let entity = TestEntity {
429            name: "Test".into(),
430            value: 100,
431        };
432        let v1 = BiTemporal::new(entity.clone()).with_recorded_by("user001");
433        let entity_id = Uuid::new_v4();
434
435        let mut chain = TemporalVersionChain::new(entity_id, v1);
436
437        let v2 = BiTemporal::new(TestEntity {
438            name: "Test".into(),
439            value: 200,
440        })
441        .with_recorded_by("user002")
442        .with_change_type(TemporalChangeType::Correction);
443
444        chain.add_version(v2);
445
446        assert_eq!(chain.version_count(), 2);
447        assert_eq!(chain.current().unwrap().inner().value, 200);
448    }
449
450    #[test]
451    fn test_temporal_change_type() {
452        assert!(TemporalChangeType::Correction.is_correction());
453        assert!(TemporalChangeType::Reversal.is_correction());
454        assert!(!TemporalChangeType::Original.is_correction());
455    }
456}