Skip to main content

turnframe_test/workflows/claim/
state.rs

1//! Persisted state, phases, obligations and outcomes of the receipt-claim
2//! sample.
3//!
4//! The type that matters here is [`Proposal`]. It is ordinary domain state —
5//! nothing in the framework knows what a proposal is — and that is the whole
6//! argument: a workflow that models proposed-but-not-applied values as its own
7//! state gets everything the shape needs out of the vocabulary that already
8//! exists.
9
10use schemars::JsonSchema;
11use serde::{Deserialize, Serialize};
12use turnframe_core::ids::AttachmentId;
13
14/// A field this domain derives from an incoming document.
15///
16/// A closed set, because it is also the parameter of an obligation and an
17/// argument the interpreter fills in: free-text field names would make two
18/// spellings of the same field two different obligations.
19#[derive(
20    Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
21)]
22#[serde(rename_all = "snake_case")]
23#[non_exhaustive]
24pub enum ClaimField {
25    /// Who issued the receipt.
26    Merchant,
27    /// The amount paid, in cents, as written on the receipt.
28    Total,
29    /// The date on the receipt.
30    ReceiptDate,
31}
32
33impl ClaimField {
34    /// Every field the domain requires, in the order a card shows them.
35    pub const ALL: [Self; 3] = [Self::Merchant, Self::Total, Self::ReceiptDate];
36
37    /// The stable identifier used in obligations and diff entries: fixed, never
38    /// derived from copy.
39    #[must_use]
40    pub const fn key(self) -> &'static str {
41        match self {
42            Self::Merchant => "merchant",
43            Self::Total => "total",
44            Self::ReceiptDate => "receipt_date",
45        }
46    }
47
48    /// Server-authored label, safe to show on a card.
49    #[must_use]
50    pub const fn label(self) -> &'static str {
51        match self {
52            Self::Merchant => "Merchant",
53            Self::Total => "Total (cents)",
54            Self::ReceiptDate => "Receipt date",
55        }
56    }
57
58    /// Italian label.
59    #[must_use]
60    pub const fn label_it(self) -> &'static str {
61        match self {
62            Self::Merchant => "Esercente",
63            Self::Total => "Totale (centesimi)",
64            Self::ReceiptDate => "Data della ricevuta",
65        }
66    }
67}
68
69/// One value read out of a document and offered for review.
70///
71/// `edited` is the difference between "this is what the extractor read" and
72/// "this is what the user typed instead", and it survives into the recorded
73/// state, so a later audit can tell a machine reading from a human correction.
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
75#[serde(deny_unknown_fields)]
76pub struct ProposedField {
77    /// Which field.
78    pub field: ClaimField,
79    /// The value, as text: the domain records what the document said, not a
80    /// parsed interpretation of it.
81    pub value: String,
82    /// `true` once the user changed it while the review was open.
83    #[serde(default)]
84    pub edited: bool,
85}
86
87/// Values derived from one document and awaiting review.
88///
89/// This is the "proposed values awaiting review" concept, in the only place it
90/// belongs: the domain's own state. The framework's view does not need a word
91/// for it, because everything the shape requires — an obligation per field, a
92/// notice naming the document, a card bound to the proposal, an act that edits
93/// a proposal distinct from the act that answers it — is expressible without
94/// one.
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
96#[serde(deny_unknown_fields)]
97pub struct Proposal {
98    /// The document the values were read from.
99    pub attachment_id: AttachmentId,
100    /// The proposed values, in [`ClaimField::ALL`] order.
101    pub fields: Vec<ProposedField>,
102}
103
104impl Proposal {
105    /// Builds a proposal, ordering the fields canonically.
106    ///
107    /// The order is normalized here rather than trusted from the caller so two
108    /// extractions that found the same values hash the same, which is what
109    /// makes the card's payload hash meaningful.
110    #[must_use]
111    pub fn new(attachment_id: AttachmentId, fields: Vec<ProposedField>) -> Self {
112        let mut fields = fields;
113        fields.sort_by_key(|proposed| proposed.field);
114        Self {
115            attachment_id,
116            fields,
117        }
118    }
119
120    /// The proposed value for a field, when there is one.
121    #[must_use]
122    pub fn field(&self, field: ClaimField) -> Option<&ProposedField> {
123        self.fields.iter().find(|proposed| proposed.field == field)
124    }
125
126    /// Required fields this proposal does not carry, in a stable order.
127    #[must_use]
128    pub fn missing(&self) -> Vec<ClaimField> {
129        ClaimField::ALL
130            .into_iter()
131            .filter(|field| self.field(*field).is_none())
132            .collect()
133    }
134
135    /// Returns `true` when every required field has a proposed value.
136    #[must_use]
137    pub fn is_complete(&self) -> bool {
138        self.missing().is_empty()
139    }
140
141    /// How many values the user changed before answering.
142    #[must_use]
143    pub fn edited_count(&self) -> usize {
144        self.fields
145            .iter()
146            .filter(|proposed| proposed.edited)
147            .count()
148    }
149}
150
151/// A value that has been reviewed and is now part of the record.
152///
153/// It keeps its provenance: which document it came from, and whether a human
154/// corrected it. A recorded field is no longer a proposal — the review is over
155/// — but "where did this number come from" stays answerable.
156#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
157#[serde(deny_unknown_fields)]
158pub struct RecordedField {
159    /// Which field.
160    pub field: ClaimField,
161    /// The accepted value.
162    pub value: String,
163    /// The document it was read from.
164    pub from_attachment: AttachmentId,
165    /// `true` when the user changed the extracted value before accepting it.
166    pub corrected: bool,
167}
168
169/// The lifecycle status stored on the case.
170#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
171#[serde(rename_all = "snake_case")]
172#[non_exhaustive]
173pub enum ClaimStatus {
174    /// Being assembled: a document may arrive, be read, and be reviewed.
175    #[default]
176    Draft,
177    /// The proposal was accepted and its values are on the record.
178    Recorded,
179    /// The document was thrown away without being recorded.
180    Discarded,
181}
182
183impl ClaimStatus {
184    /// Returns `true` while the case still accepts changes.
185    #[must_use]
186    pub const fn is_open(self) -> bool {
187        matches!(self, Self::Draft)
188    }
189}
190
191/// The persisted claim case.
192#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
193#[serde(deny_unknown_fields)]
194pub struct ClaimState {
195    /// A reference the user types by hand.
196    ///
197    /// It exists to be *outside* every proposal: editing it while a review is
198    /// open must not change what the review card is about, which is what makes
199    /// "the payload hash covers the proposal rather than the whole state" a
200    /// testable sentence rather than a slogan.
201    #[serde(default, skip_serializing_if = "Option::is_none")]
202    pub reference: Option<String>,
203    /// The document that arrived, once it has.
204    #[serde(default, skip_serializing_if = "Option::is_none")]
205    pub attachment: Option<AttachmentId>,
206    /// Values read from the document and awaiting review.
207    #[serde(default, skip_serializing_if = "Option::is_none")]
208    pub proposal: Option<Proposal>,
209    /// Values that survived a review, with their provenance.
210    #[serde(default)]
211    pub recorded: Vec<RecordedField>,
212    /// The document whose reading the user threw away, when they did.
213    #[serde(default, skip_serializing_if = "Option::is_none")]
214    pub abandoned_from: Option<AttachmentId>,
215    /// Lifecycle status.
216    pub status: ClaimStatus,
217}
218
219impl ClaimState {
220    /// The obligations open in this state, in a stable order.
221    ///
222    /// While a review is open there is one obligation per **proposed** field
223    /// and one per **missing** required field. Both are parameterized, so three
224    /// proposed values are three distinct obligations rather than one
225    /// checkpoint that flickers as the user works through them.
226    #[must_use]
227    pub fn open_obligations(&self) -> Vec<ClaimObligation> {
228        if !self.status.is_open() {
229            return Vec::new();
230        }
231        let Some(attachment) = self.attachment.as_ref() else {
232            return vec![ClaimObligation::AttachReceipt];
233        };
234        let Some(proposal) = self.proposal.as_ref() else {
235            return vec![ClaimObligation::ExtractFields {
236                attachment_id: attachment.clone(),
237            }];
238        };
239        let mut obligations: Vec<ClaimObligation> = proposal
240            .fields
241            .iter()
242            .map(|proposed| ClaimObligation::ReviewProposedField {
243                field: proposed.field,
244            })
245            .collect();
246        obligations.extend(
247            proposal
248                .missing()
249                .into_iter()
250                .map(|field| ClaimObligation::ProvideField { field }),
251        );
252        obligations
253    }
254
255    /// The recorded value of a field, when the review put one there.
256    #[must_use]
257    pub fn recorded_value(&self, field: ClaimField) -> Option<&str> {
258        self.recorded
259            .iter()
260            .find(|recorded| recorded.field == field)
261            .map(|recorded| recorded.value.as_str())
262    }
263
264    /// Returns `true` when a review is open.
265    #[must_use]
266    pub const fn is_awaiting_review(&self) -> bool {
267        self.proposal.is_some() && self.status.is_open()
268    }
269}
270
271/// Exactly one lifecycle phase.
272#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
273#[serde(rename_all = "snake_case")]
274#[non_exhaustive]
275pub enum ClaimPhase {
276    /// The case does not exist yet.
277    PreDraft,
278    /// No document has arrived.
279    AwaitingDocument,
280    /// A document is here and nothing has been read out of it yet.
281    Extracting,
282    /// Values are proposed and the review card is waiting for an answer.
283    ///
284    /// The phase is user-owned, and — as the request that prompted this sample
285    /// pointed out — for a reason the user did not initiate: a document
286    /// arrived, not a decision.
287    AwaitingReview,
288    /// The values are on the record.
289    Recorded,
290    /// The document was thrown away.
291    Discarded,
292}
293
294/// An open obligation, parameterized where the domain has more than one of it.
295#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
296#[serde(rename_all = "snake_case")]
297#[non_exhaustive]
298pub enum ClaimObligation {
299    /// No document has arrived yet.
300    AttachReceipt,
301    /// A document is here and nothing has been read out of it.
302    ExtractFields {
303        /// The document waiting to be read.
304        attachment_id: AttachmentId,
305    },
306    /// One proposed value is waiting for the user to accept or change it.
307    ReviewProposedField {
308        /// The field that was proposed.
309        field: ClaimField,
310    },
311    /// One required field the document did not yield.
312    ProvideField {
313        /// The field nobody has a value for.
314        field: ClaimField,
315    },
316}
317
318/// A terminal outcome.
319#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
320#[serde(rename_all = "snake_case")]
321#[non_exhaustive]
322pub enum ClaimOutcome {
323    /// The reviewed values are on the record.
324    Recorded,
325    /// The document was thrown away without being recorded.
326    Discarded,
327}