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}