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