Skip to main content

turnframe_test/workflows/claim/
definition.rs

1//! The claim [`WorkflowDefinition`], its pure transition function, its review
2//! card and its receipts.
3
4use serde::de::DeserializeOwned;
5use turnframe_core::case::CaseRef;
6use turnframe_core::command::{
7    AtomicityScope, ClaimMode, CommandPolicy, ConfirmationPolicy, RiskClass,
8};
9use turnframe_core::error::DomainRejection;
10use turnframe_core::event::{OperationalReceipt, ReceiptEvent, ReceiptSeverity, RedactedEvent};
11use turnframe_core::flow::{
12    InteractionRequirement, PhaseOwnership, ViewOf, WorkflowDefinition, WorkflowNotice,
13    WorkflowView,
14};
15use turnframe_core::hash::canonical_digest;
16use turnframe_core::ids::{AttachmentId, OperationKey, ReceiptId, WorkflowKey, WorkflowVersion};
17use turnframe_core::interaction::{
18    FieldValue, InteractionKind, InteractionOption, InteractionPayload, InteractionSpec,
19    OptionStyle, ReviewDiffEntry, StoredInteractionAction,
20};
21use turnframe_core::locale::{Locale, LocalizedText};
22use turnframe_core::operation::{DateDirection, OperationSpec};
23use turnframe_core::plan::TargetPolicy;
24use turnframe_core::response::NoticeSeverity;
25use turnframe_core::target::{ResolvedAct, ResolvedActKind};
26
27use crate::workflows::claim::command::{
28    AttachArgs, ClaimCommand, ClaimEvent, ReferenceArgs, ReviseArgs, operations,
29};
30use crate::workflows::claim::state::{
31    ClaimField, ClaimObligation, ClaimOutcome, ClaimPhase, ClaimState, ClaimStatus, Proposal,
32    ProposedField, RecordedField,
33};
34use crate::workflows::{Applied, PureWorkflow};
35
36/// Stable rejection codes of the claim sample.
37pub mod rejection {
38    /// The case does not exist yet.
39    pub const NOT_FOUND: &str = "claim.not_found";
40    /// The case already exists.
41    pub const ALREADY_EXISTS: &str = "claim.already_exists";
42    /// The case is closed and no longer accepts changes.
43    pub const CLOSED: &str = "claim.closed";
44    /// The reference is blank.
45    pub const REFERENCE_EMPTY: &str = "claim.reference_empty";
46    /// A document is already attached.
47    pub const RECEIPT_ALREADY_ATTACHED: &str = "claim.receipt_already_attached";
48    /// No document has arrived, so nothing can have been read out of one.
49    pub const NO_RECEIPT: &str = "claim.no_receipt";
50    /// The proposal names a document other than the attached one.
51    pub const WRONG_RECEIPT: &str = "claim.wrong_receipt";
52    /// A review is already open, and a second reading would silently replace
53    /// values the user is looking at.
54    pub const REVIEW_ALREADY_OPEN: &str = "claim.review_already_open";
55    /// Nothing is under review.
56    pub const NO_REVIEW: &str = "claim.no_review";
57    /// The reading produced no values at all.
58    pub const PROPOSAL_EMPTY: &str = "claim.proposal_empty";
59    /// The reading proposed the same field twice.
60    pub const PROPOSAL_DUPLICATE_FIELD: &str = "claim.proposal_duplicate_field";
61    /// A proposed value is blank.
62    pub const PROPOSED_VALUE_EMPTY: &str = "claim.proposed_value_empty";
63    /// A required field has no value, so the proposal cannot be accepted.
64    pub const PROPOSAL_INCOMPLETE: &str = "claim.proposal_incomplete";
65    /// The workflow does not compile this kind of act.
66    pub const UNSUPPORTED_ACT: &str = "claim.unsupported_act";
67    /// The operation is not in the catalog.
68    pub const UNKNOWN_OPERATION: &str = "claim.unknown_operation";
69    /// The arguments do not match the operation's schema.
70    pub const INVALID_ARGUMENTS: &str = "claim.invalid_arguments";
71}
72
73/// Key of the blocking review requirement.
74pub const REVIEW_KEY: &str = "claim.proposal_review";
75
76/// Option that accepts the proposal.
77pub const ACCEPT_OPTION: &str = "accept";
78
79/// Option that throws the reading away and keeps the document.
80pub const ABANDON_OPTION: &str = "abandon";
81
82/// Option that answers nothing and leaves the proposal alone.
83pub const NOT_NOW_OPTION: &str = "not_now";
84
85/// Notice code carried while a proposal is open.
86pub const PROPOSED_NOTICE: &str = "claim.fields_proposed_from_attachment";
87
88/// Notice code carried when the user changed proposed values.
89pub const EDITED_NOTICE: &str = "claim.proposal_edited";
90
91/// Notice code carried after a reading was thrown away.
92pub const ABANDONED_NOTICE: &str = "claim.proposal_abandoned";
93
94/// Longest accepted value, in characters.
95pub const MAX_VALUE_CHARS: usize = 200;
96
97fn reject(code: &'static str) -> DomainRejection {
98    let suffix = code.strip_prefix("claim.").unwrap_or(code);
99    DomainRejection::new(code, format!("claim.error.{suffix}"))
100}
101
102fn parse<T: DeserializeOwned>(arguments: &serde_json::Value) -> Result<T, DomainRejection> {
103    serde_json::from_value(arguments.clone()).map_err(|_| reject(rejection::INVALID_ARGUMENTS))
104}
105
106fn open(state: &ClaimState) -> Result<(), DomainRejection> {
107    if state.status.is_open() {
108        Ok(())
109    } else {
110        Err(reject(rejection::CLOSED))
111    }
112}
113
114fn check_value(value: &str) -> Result<String, DomainRejection> {
115    let trimmed = value.trim();
116    if trimmed.is_empty() || trimmed.chars().count() > MAX_VALUE_CHARS {
117        return Err(reject(rejection::PROPOSED_VALUE_EMPTY)
118            .with_details(serde_json::json!({ "max_chars": MAX_VALUE_CHARS })));
119    }
120    Ok(trimmed.to_owned())
121}
122
123/// Checks a command against the current state without changing anything.
124pub fn validate(state: Option<&ClaimState>, command: &ClaimCommand) -> Result<(), DomainRejection> {
125    let Some(state) = state else {
126        return match command {
127            ClaimCommand::CreateDraft => Ok(()),
128            _ => Err(reject(rejection::NOT_FOUND)),
129        };
130    };
131    match command {
132        ClaimCommand::CreateDraft => Err(reject(rejection::ALREADY_EXISTS)),
133        ClaimCommand::SetReference { value } => {
134            open(state)?;
135            if value.trim().is_empty() {
136                return Err(reject(rejection::REFERENCE_EMPTY));
137            }
138            Ok(())
139        }
140        ClaimCommand::AttachReceipt { .. } => {
141            open(state)?;
142            if state.attachment.is_some() {
143                return Err(reject(rejection::RECEIPT_ALREADY_ATTACHED));
144            }
145            Ok(())
146        }
147        ClaimCommand::ProposeFields {
148            attachment_id,
149            fields,
150        } => {
151            open(state)?;
152            let Some(attached) = state.attachment.as_ref() else {
153                return Err(reject(rejection::NO_RECEIPT));
154            };
155            if attached != attachment_id {
156                return Err(reject(rejection::WRONG_RECEIPT));
157            }
158            // A second reading may not overwrite a proposal the user is looking
159            // at: the open review has to be answered or abandoned first.
160            if state.proposal.is_some() {
161                return Err(reject(rejection::REVIEW_ALREADY_OPEN));
162            }
163            if fields.is_empty() {
164                return Err(reject(rejection::PROPOSAL_EMPTY));
165            }
166            let mut seen = std::collections::BTreeSet::new();
167            for proposed in fields {
168                if !seen.insert(proposed.field) {
169                    return Err(reject(rejection::PROPOSAL_DUPLICATE_FIELD));
170                }
171                check_value(&proposed.value)?;
172            }
173            Ok(())
174        }
175        ClaimCommand::ReviseProposedField { value, .. } => {
176            open(state)?;
177            if state.proposal.is_none() {
178                return Err(reject(rejection::NO_REVIEW));
179            }
180            check_value(value)?;
181            Ok(())
182        }
183        ClaimCommand::AcceptProposal => {
184            open(state)?;
185            let Some(proposal) = state.proposal.as_ref() else {
186                return Err(reject(rejection::NO_REVIEW));
187            };
188            if proposal.is_complete() {
189                Ok(())
190            } else {
191                Err(
192                    reject(rejection::PROPOSAL_INCOMPLETE).with_details(serde_json::json!({
193                        "missing": proposal
194                            .missing()
195                            .into_iter()
196                            .map(ClaimField::key)
197                            .collect::<Vec<_>>(),
198                    })),
199                )
200            }
201        }
202        ClaimCommand::AbandonReview => {
203            open(state)?;
204            if state.proposal.is_some() {
205                Ok(())
206            } else {
207                Err(reject(rejection::NO_REVIEW))
208            }
209        }
210        ClaimCommand::DiscardReceipt => open(state),
211    }
212}
213
214/// Applies a command, producing the next state and the events to commit.
215pub fn apply(
216    state: Option<&ClaimState>,
217    command: &ClaimCommand,
218) -> Result<Applied<ClaimState, ClaimEvent>, DomainRejection> {
219    validate(state, command)?;
220    let Some(state) = state else {
221        return Ok(Applied::new(
222            ClaimState::default(),
223            vec![ClaimEvent::DraftCreated],
224        ));
225    };
226    let mut next = state.clone();
227    let event = match command {
228        ClaimCommand::CreateDraft => return Err(reject(rejection::ALREADY_EXISTS)),
229        ClaimCommand::SetReference { value } => {
230            let value = value.trim().to_owned();
231            next.reference = Some(value.clone());
232            ClaimEvent::ReferenceSet { value }
233        }
234        ClaimCommand::AttachReceipt { attachment_id } => {
235            next.attachment = Some(attachment_id.clone());
236            ClaimEvent::ReceiptAttached {
237                attachment_id: attachment_id.clone(),
238            }
239        }
240        ClaimCommand::ProposeFields {
241            attachment_id,
242            fields,
243        } => {
244            let normalized: Vec<ProposedField> = fields
245                .iter()
246                .map(|proposed| ProposedField {
247                    field: proposed.field,
248                    value: proposed.value.trim().to_owned(),
249                    edited: false,
250                })
251                .collect();
252            let proposal = Proposal::new(attachment_id.clone(), normalized);
253            let fields = proposal.fields.clone();
254            next.proposal = Some(proposal);
255            // A fresh reading supersedes the memory of the abandoned one.
256            next.abandoned_from = None;
257            ClaimEvent::FieldsProposed {
258                attachment_id: attachment_id.clone(),
259                fields,
260            }
261        }
262        ClaimCommand::ReviseProposedField { field, value } => {
263            let value = value.trim().to_owned();
264            let Some(proposal) = next.proposal.as_mut() else {
265                return Err(reject(rejection::NO_REVIEW));
266            };
267            let previous = proposal
268                .field(*field)
269                .map(|proposed| proposed.value.clone());
270            proposal.fields.retain(|proposed| proposed.field != *field);
271            proposal.fields.push(ProposedField {
272                field: *field,
273                value: value.clone(),
274                // The mark that outlives the review: a human put this here.
275                edited: true,
276            });
277            proposal.fields.sort_by_key(|proposed| proposed.field);
278            ClaimEvent::ProposedFieldRevised {
279                field: *field,
280                previous,
281                value,
282            }
283        }
284        ClaimCommand::AcceptProposal => {
285            let Some(proposal) = next.proposal.take() else {
286                return Err(reject(rejection::NO_REVIEW));
287            };
288            let recorded: Vec<RecordedField> = proposal
289                .fields
290                .iter()
291                .map(|proposed| RecordedField {
292                    field: proposed.field,
293                    value: proposed.value.clone(),
294                    from_attachment: proposal.attachment_id.clone(),
295                    corrected: proposed.edited,
296                })
297                .collect();
298            next.recorded = recorded.clone();
299            next.status = ClaimStatus::Recorded;
300            ClaimEvent::ProposalAccepted {
301                attachment_id: proposal.attachment_id,
302                fields: recorded,
303            }
304        }
305        ClaimCommand::AbandonReview => {
306            let Some(proposal) = next.proposal.take() else {
307                return Err(reject(rejection::NO_REVIEW));
308            };
309            // This domain's explicit choice: the reading goes, the document
310            // stays, and the case says so.
311            next.abandoned_from = Some(proposal.attachment_id.clone());
312            ClaimEvent::ProposalAbandoned {
313                attachment_id: proposal.attachment_id,
314            }
315        }
316        ClaimCommand::DiscardReceipt => {
317            next.proposal = None;
318            next.status = ClaimStatus::Discarded;
319            ClaimEvent::ReceiptDiscarded
320        }
321    };
322    Ok(Applied::new(next, vec![event]))
323}
324
325/// The receipt-claim workflow.
326#[derive(Debug, Clone, Copy, Default)]
327#[non_exhaustive]
328pub struct ClaimWorkflow;
329
330impl ClaimWorkflow {
331    /// Builds the workflow.
332    #[must_use]
333    pub const fn new() -> Self {
334        Self
335    }
336
337    /// The phase a state projects to.
338    #[must_use]
339    pub fn phase_of(state: &ClaimState) -> ClaimPhase {
340        match state.status {
341            ClaimStatus::Recorded => ClaimPhase::Recorded,
342            ClaimStatus::Discarded => ClaimPhase::Discarded,
343            ClaimStatus::Draft if state.proposal.is_some() => ClaimPhase::AwaitingReview,
344            ClaimStatus::Draft if state.attachment.is_some() => ClaimPhase::Extracting,
345            ClaimStatus::Draft => ClaimPhase::AwaitingDocument,
346        }
347    }
348
349    fn compile_operation(
350        operation: &OperationKey,
351        arguments: &serde_json::Value,
352    ) -> Result<Vec<ClaimCommand>, DomainRejection> {
353        let command = match operation.as_str() {
354            operations::CREATE_DRAFT => ClaimCommand::CreateDraft,
355            operations::SET_REFERENCE => ClaimCommand::SetReference {
356                value: parse::<ReferenceArgs>(arguments)?.value,
357            },
358            operations::ATTACH_RECEIPT => ClaimCommand::AttachReceipt {
359                attachment_id: AttachmentId::from(parse::<AttachArgs>(arguments)?.attachment_id),
360            },
361            operations::REVISE_PROPOSED_FIELD => {
362                let args = parse::<ReviseArgs>(arguments)?;
363                // Each field is read in its own shape: a total in cents, a date as a date.
364                let value = match args.field {
365                    ClaimField::Merchant => args.merchant,
366                    ClaimField::Total => args.total.map(|total| total.minor.to_string()),
367                    ClaimField::ReceiptDate => args.date.map(|date| date.to_string()),
368                };
369                ClaimCommand::ReviseProposedField {
370                    field: args.field,
371                    value: value.ok_or_else(|| reject(rejection::INVALID_ARGUMENTS))?,
372                }
373            }
374            operations::ACCEPT_PROPOSAL => ClaimCommand::AcceptProposal,
375            operations::ABANDON_REVIEW => ClaimCommand::AbandonReview,
376            operations::DISCARD_RECEIPT => ClaimCommand::DiscardReceipt,
377            // The reserved code, not this workflow's own: it says which of the
378            // two mistakes this is, so the state explorer can report a
379            // catalogue that offers what the compiler does not know.
380            _ => return Err(reject(turnframe_core::error::UNKNOWN_OPERATION)),
381        };
382        Ok(vec![command])
383    }
384}
385
386fn operation(key: &str, summary: &str) -> OperationSpec {
387    OperationSpec::new(key)
388        .summary(summary)
389        .target(TargetPolicy::RequiresExistingCase)
390        .mutating()
391}
392
393/// The diff the review card shows: one line per proposed value.
394fn review_entries(state: &ClaimState, proposal: &Proposal) -> Vec<ReviewDiffEntry> {
395    proposal
396        .fields
397        .iter()
398        .map(|proposed| {
399            let before = state
400                .recorded_value(proposed.field)
401                .map_or(FieldValue::Absent, FieldValue::present);
402            ReviewDiffEntry::new(
403                proposed.field.key(),
404                LocalizedText::new(proposed.field.label()).with("it", proposed.field.label_it()),
405            )
406            .with_before(before)
407            .with_after(FieldValue::present(proposed.value.clone()))
408        })
409        .collect()
410}
411
412/// The blocking review card of the `AwaitingReview` phase.
413///
414/// Its payload is the proposal and nothing else: the diff entries are the
415/// proposed values, the copy counts them and names the document, and no part of
416/// the case outside the proposal appears. That is what makes the payload hash
417/// cover the proposal rather than the whole state — not a feature, a
418/// consequence of putting the proposal in the payload.
419fn review_requirement(state: &ClaimState, proposal: &Proposal) -> InteractionRequirement {
420    let count = proposal.fields.len();
421    let mut payload = InteractionPayload::new(
422        LocalizedText::new(format!("Review {count} value(s) read from the receipt"))
423            .with("it", format!("Controlla {count} valore/i letti dalla ricevuta")),
424    )
425    .with_body(
426        LocalizedText::new(
427            "These values were read from the attached receipt. Nothing has been recorded yet.",
428        )
429        .with(
430            "it",
431            "Questi valori sono stati letti dalla ricevuta allegata. Non è stato ancora registrato nulla.",
432        ),
433    );
434    for entry in review_entries(state, proposal) {
435        payload = payload.with_review_entry(entry);
436    }
437    // A proposal that is missing a required value cannot be accepted, so the
438    // card does not offer to accept it: an option nobody can act on is a worse
439    // answer than an option that is not there.
440    if proposal.is_complete() {
441        payload = payload.with_option(
442            InteractionOption::new(
443                ACCEPT_OPTION,
444                LocalizedText::new("Record these values").with("it", "Registra questi valori"),
445                StoredInteractionAction::ApplyOperation {
446                    operation: OperationKey::from(operations::ACCEPT_PROPOSAL),
447                    arguments: serde_json::Value::Null,
448                    freeform_argument: None,
449                },
450            )
451            .with_style(OptionStyle::Primary),
452        );
453    }
454    payload = payload
455        .with_option(
456            InteractionOption::new(
457                ABANDON_OPTION,
458                LocalizedText::new("Discard this reading").with("it", "Scarta questa lettura"),
459                StoredInteractionAction::ApplyOperation {
460                    operation: OperationKey::from(operations::ABANDON_REVIEW),
461                    arguments: serde_json::Value::Null,
462                    freeform_argument: None,
463                },
464            )
465            .with_style(OptionStyle::Danger),
466        )
467        .with_option(InteractionOption::new(
468            NOT_NOW_OPTION,
469            LocalizedText::new("Not now").with("it", "Non ora"),
470            // Declining is *not* abandoning: nothing is committed, so the
471            // proposal survives and the card is derived again next turn.
472            StoredInteractionAction::DeclineCommands,
473        ));
474    InteractionRequirement::blocking(REVIEW_KEY, InteractionKind::ReviewChanges)
475        .with_confirms_risk(RiskClass::SensitiveDataChange)
476        .with_payload(payload)
477}
478
479fn proposed_notice(proposal: &Proposal) -> WorkflowNotice {
480    let count = proposal.fields.len();
481    let document = proposal.attachment_id.as_str();
482    WorkflowNotice {
483        code: PROPOSED_NOTICE.to_owned(),
484        severity: NoticeSeverity::Info,
485        text: LocalizedText::new(format!(
486            "{count} value(s) are proposed from receipt {document} and are not recorded yet."
487        ))
488        .with(
489            "it",
490            format!(
491                "{count} valore/i sono proposti dalla ricevuta {document} e non sono ancora registrati."
492            ),
493        ),
494    }
495}
496
497fn edited_notice(proposal: &Proposal) -> WorkflowNotice {
498    let edited = proposal.edited_count();
499    WorkflowNotice {
500        code: EDITED_NOTICE.to_owned(),
501        severity: NoticeSeverity::Info,
502        text: LocalizedText::new(format!(
503            "{edited} of the proposed value(s) were changed by hand."
504        ))
505        .with(
506            "it",
507            format!("{edited} dei valori proposti sono stati modificati a mano."),
508        ),
509    }
510}
511
512fn abandoned_notice(attachment: &AttachmentId) -> WorkflowNotice {
513    let document = attachment.as_str();
514    WorkflowNotice {
515        code: ABANDONED_NOTICE.to_owned(),
516        severity: NoticeSeverity::Info,
517        text: LocalizedText::new(format!(
518            "The reading of receipt {document} was discarded. The receipt is still attached and can be read again."
519        ))
520        .with(
521            "it",
522            format!(
523                "La lettura della ricevuta {document} è stata scartata. La ricevuta resta allegata e si può rileggere."
524            ),
525        ),
526    }
527}
528
529impl WorkflowDefinition for ClaimWorkflow {
530    type State = ClaimState;
531    type Phase = ClaimPhase;
532    type Obligation = ClaimObligation;
533    type Command = ClaimCommand;
534    type Event = ClaimEvent;
535    type Outcome = ClaimOutcome;
536
537    fn key(&self) -> WorkflowKey {
538        WorkflowKey::from("claim")
539    }
540
541    fn version(&self) -> WorkflowVersion {
542        WorkflowVersion::from("1")
543    }
544
545    fn phase_ownership(&self, phase: &ClaimPhase) -> PhaseOwnership {
546        match phase {
547            ClaimPhase::PreDraft | ClaimPhase::AwaitingDocument | ClaimPhase::Extracting => {
548                PhaseOwnership::System
549            }
550            ClaimPhase::AwaitingReview => PhaseOwnership::User,
551            ClaimPhase::Recorded | ClaimPhase::Discarded => PhaseOwnership::Terminal,
552        }
553    }
554
555    fn project(&self, case_ref: CaseRef, state: Option<&ClaimState>) -> ViewOf<Self> {
556        let version = self.version();
557        let Some(state) = state else {
558            return WorkflowView::new(case_ref, version, ClaimPhase::PreDraft);
559        };
560        let phase = Self::phase_of(state);
561        let mut view =
562            WorkflowView::new(case_ref, version, phase).with_obligations(state.open_obligations());
563        if let Some(proposal) = state.proposal.as_ref() {
564            // "These N fields are proposed from attachment A", said in this
565            // domain's own words rather than in a vocabulary the framework had
566            // to grow for it.
567            view = view.with_notice(proposed_notice(proposal));
568            if proposal.edited_count() > 0 {
569                view = view.with_notice(edited_notice(proposal));
570            }
571        } else if let (Some(attachment), true) =
572            (state.abandoned_from.as_ref(), state.status.is_open())
573        {
574            view = view.with_notice(abandoned_notice(attachment));
575        }
576        match (phase, state.proposal.as_ref()) {
577            (ClaimPhase::AwaitingReview, Some(proposal)) => {
578                view.with_blocking_interaction(review_requirement(state, proposal))
579            }
580            (ClaimPhase::Recorded, _) => view.with_outcome(ClaimOutcome::Recorded),
581            (ClaimPhase::Discarded, _) => view.with_outcome(ClaimOutcome::Discarded),
582            _ => view,
583        }
584    }
585
586    fn summary(&self) -> Option<String> {
587        Some(String::from(
588            "Expense claims: read a receipt the user sends and record what it says.",
589        ))
590    }
591
592    fn noun(&self) -> Option<LocalizedText> {
593        Some(LocalizedText::new("expense claim").with("it", "nota spese"))
594    }
595
596    fn operations(&self, view: &ViewOf<Self>) -> Vec<OperationSpec> {
597        super::super::in_italian(Self::offered(view), ITALIAN)
598    }
599
600    fn compile_act(
601        &self,
602        _state: Option<&ClaimState>,
603        _view: &ViewOf<Self>,
604        act: &ResolvedAct,
605    ) -> Result<Vec<ClaimCommand>, DomainRejection> {
606        match &act.kind {
607            ResolvedActKind::StartWorkflow => Ok(vec![ClaimCommand::CreateDraft]),
608            ResolvedActKind::ApplyOperation { operation } => {
609                Self::compile_operation(operation, &act.arguments)
610            }
611            _ => Err(reject(rejection::UNSUPPORTED_ACT)),
612        }
613    }
614
615    fn command_policy(&self, _state: Option<&ClaimState>, command: &ClaimCommand) -> CommandPolicy {
616        match command {
617            // Answering the review writes derived values into the record: a
618            // review card, not a sentence, is what authorizes it.
619            ClaimCommand::AcceptProposal => CommandPolicy {
620                risk: RiskClass::SensitiveDataChange,
621                confirmation: ConfirmationPolicy::ReviewCard,
622                atomicity: AtomicityScope::PerCase,
623                claim_mode: ClaimMode::EventReferencedParaphrase,
624            },
625            ClaimCommand::DiscardReceipt => CommandPolicy {
626                risk: RiskClass::Destructive,
627                confirmation: ConfirmationPolicy::ExplicitClick,
628                atomicity: AtomicityScope::PerCase,
629                claim_mode: ClaimMode::ServerReceiptOnly,
630            },
631            ClaimCommand::AbandonReview => CommandPolicy {
632                risk: RiskClass::ReversibleLowRisk,
633                confirmation: ConfirmationPolicy::ExplicitClick,
634                atomicity: AtomicityScope::PerCase,
635                claim_mode: ClaimMode::EventReferencedParaphrase,
636            },
637            // Server-issued: the values come from an extractor, so the receipt
638            // is the only thing allowed to state what was read.
639            ClaimCommand::ProposeFields { .. } => CommandPolicy {
640                risk: RiskClass::ReversibleLowRisk,
641                confirmation: ConfirmationPolicy::None,
642                atomicity: AtomicityScope::PerCase,
643                claim_mode: ClaimMode::ServerReceiptOnly,
644            },
645            // Editing a proposal changes nothing outside the proposal, which is
646            // exactly why it is not the same act as answering the review.
647            ClaimCommand::CreateDraft
648            | ClaimCommand::SetReference { .. }
649            | ClaimCommand::AttachReceipt { .. }
650            | ClaimCommand::ReviseProposedField { .. } => CommandPolicy::low_risk(),
651        }
652    }
653
654    fn validate_command(
655        &self,
656        state: Option<&ClaimState>,
657        command: &ClaimCommand,
658    ) -> Result<(), DomainRejection> {
659        validate(state, command)
660    }
661
662    fn receipts(
663        &self,
664        events: &[ReceiptEvent<ClaimEvent>],
665        locale: &Locale,
666    ) -> Vec<OperationalReceipt> {
667        let _ = locale;
668        events
669            .iter()
670            .map(|event| match event {
671                ReceiptEvent::Committed(committed) => {
672                    let event_ids = vec![committed.event_id];
673                    let status_code = committed.payload.event_type().to_owned();
674                    let (severity, title, body) = receipt_copy(&committed.payload);
675                    OperationalReceipt {
676                        receipt_id: ReceiptId::derive(&event_ids, &status_code),
677                        event_ids,
678                        severity,
679                        title,
680                        body,
681                        status_code,
682                        artifact_refs: Vec::new(),
683                    }
684                }
685                ReceiptEvent::Redacted(redacted) => redacted_receipt(redacted),
686            })
687            .collect()
688    }
689
690    /// Binds the card to the **proposal**, not to the whole state.
691    ///
692    /// The trip sample puts a digest of the entire case in its rebooking card's
693    /// metadata, because the entire case is what that card is about. This card is
694    /// about the proposal, so the proposal is what it hashes — and
695    /// the case revision is deliberately *not* in the metadata, because
696    /// revision binding is already the interaction's own mechanism and putting
697    /// it in the payload would make the payload hash change for every unrelated
698    /// edit.
699    fn build_interaction(
700        &self,
701        state: Option<&ClaimState>,
702        view: &ViewOf<Self>,
703        requirement: &InteractionRequirement,
704    ) -> Result<InteractionSpec, DomainRejection> {
705        let mut spec = requirement.to_spec(view.case_ref.clone());
706        if let Some(proposal) = state.and_then(|state| state.proposal.as_ref()) {
707            let digest =
708                canonical_digest(proposal).map_err(|_| reject(rejection::INVALID_ARGUMENTS))?;
709            spec.payload = spec.payload.with_metadata(serde_json::json!({
710                "proposal_hash": digest.as_str(),
711                "attachment_id": proposal.attachment_id.as_str(),
712            }));
713        }
714        Ok(spec)
715    }
716}
717
718impl ClaimWorkflow {
719    /// The operations a view offers, with their summaries in English.
720    fn offered(view: &ViewOf<Self>) -> Vec<OperationSpec> {
721        let reference = || {
722            operation(
723                operations::SET_REFERENCE,
724                "Set the reference the user types by hand. It is not part of any proposal.",
725            )
726            .arguments::<ReferenceArgs>()
727            .argument("value", |a| {
728                a.label("reference")
729                    .label_in("it-IT", "riferimento")
730                    .required()
731            })
732        };
733        let discard = || {
734            operation(
735                operations::DISCARD_RECEIPT,
736                "Throw the receipt away without recording anything.",
737            )
738        };
739        match view.phase {
740            ClaimPhase::PreDraft => vec![
741                operation(operations::CREATE_DRAFT, "Start an expense claim.")
742                    .target(TargetPolicy::AllowsNewCase),
743            ],
744            ClaimPhase::AwaitingDocument => vec![
745                reference(),
746                operation(
747                    operations::ATTACH_RECEIPT,
748                    "Attach a receipt that arrived with this turn.",
749                )
750                .arguments::<AttachArgs>()
751                .argument("attachment_id", |a| a.written().required()),
752                discard(),
753            ],
754            ClaimPhase::Extracting => vec![reference(), discard()],
755            ClaimPhase::AwaitingReview => vec![
756                // Editing a proposed value and answering the review are two operations.
757                operation(
758                    operations::REVISE_PROPOSED_FIELD,
759                    "Change one value proposed from the receipt. The review stays open.",
760                )
761                .arguments::<ReviseArgs>()
762                .argument("field", |a| {
763                    a.label("field")
764                        .label_in("it-IT", "campo")
765                        .describe("Which proposed value the user names: the merchant, the total or the receipt's date.")
766                        .required()
767                })
768                .argument("merchant", |a| {
769                    a.label("merchant").label_in("it-IT", "esercente")
770                })
771                .argument("total", |a| {
772                    a.label("total").label_in("it-IT", "totale").money()
773                })
774                .argument("date", |a| {
775                    a.label("receipt date")
776                        .label_in("it-IT", "data della ricevuta")
777                        .date_direction(DateDirection::Past)
778                })
779                .example(
780                    "the total is 180 euros",
781                    serde_json::json!({
782                        "field": "total",
783                        "total": { "minor": 18_000, "currency": "EUR" }
784                    }),
785                )
786                .example(
787                    "the merchant is Café Aurora",
788                    serde_json::json!({ "field": "merchant", "merchant": "Café Aurora" }),
789                ),
790                operation(
791                    operations::ACCEPT_PROPOSAL,
792                    "Accept the proposed values: they become the record.",
793                ),
794                operation(
795                    operations::ABANDON_REVIEW,
796                    "Throw the reading away and keep the receipt, so it can be read again.",
797                ),
798                reference(),
799                discard(),
800            ],
801            ClaimPhase::Recorded | ClaimPhase::Discarded => Vec::new(),
802        }
803    }
804}
805
806/// What each operation does, in Italian.
807const ITALIAN: &[(&str, &str)] = &[
808    (operations::CREATE_DRAFT, "Apre una nota spese."),
809    (
810        operations::SET_REFERENCE,
811        "Imposta il riferimento che l'utente scrive a mano. Non fa parte di alcuna proposta.",
812    ),
813    (
814        operations::ATTACH_RECEIPT,
815        "Allega una ricevuta arrivata con questo turno.",
816    ),
817    (
818        operations::REVISE_PROPOSED_FIELD,
819        "Cambia un valore proposto dalla ricevuta. La revisione resta aperta.",
820    ),
821    (
822        operations::ACCEPT_PROPOSAL,
823        "Accetta i valori proposti: diventano il record.",
824    ),
825    (
826        operations::ABANDON_REVIEW,
827        "Scarta la lettura e tiene la ricevuta, così si può leggere di nuovo.",
828    ),
829    (
830        operations::DISCARD_RECEIPT,
831        "Scarta la ricevuta senza registrare nulla.",
832    ),
833];
834
835/// Renders an event whose payload was erased.
836///
837/// The values this domain records were read out of a document a person sent in,
838/// so an erasure request reaches them, and the proposal that quoted them is
839/// exactly the kind of receipt that cannot be re-rendered afterwards. The
840/// erased receipt keeps the one thing that is still true: a step happened, and
841/// it is still in the ledger.
842fn redacted_receipt(redacted: &RedactedEvent) -> OperationalReceipt {
843    let event_ids = vec![redacted.event_id];
844    let status_code = "claim.detail_erased".to_owned();
845    OperationalReceipt {
846        receipt_id: ReceiptId::derive(&event_ids, &status_code),
847        event_ids,
848        severity: ReceiptSeverity::Info,
849        title: LocalizedText::new("Detail erased").with("it", "Dettaglio cancellato"),
850        body: LocalizedText::new(
851            "This step is still on record; the detail of what it recorded was erased.",
852        )
853        .with(
854            "it",
855            "Questo passaggio resta a registro; il dettaglio di cosa ha registrato è stato cancellato.",
856        ),
857        status_code,
858        artifact_refs: Vec::new(),
859    }
860}
861
862/// Server-authored copy, in English with an Italian translation.
863fn receipt_copy(event: &ClaimEvent) -> (ReceiptSeverity, LocalizedText, LocalizedText) {
864    match event {
865        ClaimEvent::DraftCreated => (
866            ReceiptSeverity::Success,
867            LocalizedText::new("Claim opened").with("it", "Nota spese aperta"),
868            LocalizedText::new("A new expense claim is open.")
869                .with("it", "È aperta una nuova nota spese."),
870        ),
871        ClaimEvent::ReferenceSet { value } => (
872            ReceiptSeverity::Success,
873            LocalizedText::new("Reference set").with("it", "Riferimento impostato"),
874            LocalizedText::new(format!("The reference is now \"{value}\"."))
875                .with("it", format!("Il riferimento ora è \"{value}\".")),
876        ),
877        ClaimEvent::ReceiptAttached { attachment_id } => (
878            ReceiptSeverity::Success,
879            LocalizedText::new("Receipt attached").with("it", "Ricevuta allegata"),
880            LocalizedText::new(format!("Receipt {attachment_id} is attached."))
881                .with("it", format!("La ricevuta {attachment_id} è allegata.")),
882        ),
883        ClaimEvent::FieldsProposed { fields, .. } => {
884            let count = fields.len();
885            (
886                ReceiptSeverity::Success,
887                LocalizedText::new("Values proposed").with("it", "Valori proposti"),
888                LocalizedText::new(format!(
889                    "{count} value(s) were read from the receipt and are waiting for review. \
890                     Nothing has been recorded."
891                ))
892                .with(
893                    "it",
894                    format!(
895                        "{count} valore/i sono stati letti dalla ricevuta e attendono conferma. \
896                         Non e stato registrato nulla."
897                    ),
898                ),
899            )
900        }
901        ClaimEvent::ProposedFieldRevised { field, .. } => {
902            let label = field.label();
903            (
904                ReceiptSeverity::Success,
905                LocalizedText::new("Proposed value changed").with("it", "Valore proposto corretto"),
906                LocalizedText::new(format!(
907                    "\"{label}\" now holds what you typed. The review is still open."
908                ))
909                .with(
910                    "it",
911                    format!("\"{label}\" ora contiene quanto hai scritto. La conferma resta aperta."),
912                ),
913            )
914        }
915        ClaimEvent::ProposalAccepted { fields, .. } => {
916            let count = fields.len();
917            (
918                ReceiptSeverity::Success,
919                LocalizedText::new("Values recorded").with("it", "Valori registrati"),
920                LocalizedText::new(format!("{count} value(s) are now on the record."))
921                    .with("it", format!("{count} valore/i ora sono registrati.")),
922            )
923        }
924        ClaimEvent::ProposalAbandoned { attachment_id } => (
925            ReceiptSeverity::Warning,
926            LocalizedText::new("Reading discarded").with("it", "Lettura scartata"),
927            LocalizedText::new(format!(
928                "The reading of receipt {attachment_id} was discarded. The receipt is still attached."
929            ))
930            .with(
931                "it",
932                format!(
933                    "La lettura della ricevuta {attachment_id} è stata scartata. La ricevuta resta allegata."
934                ),
935            ),
936        ),
937        ClaimEvent::ReceiptDiscarded => (
938            ReceiptSeverity::Warning,
939            LocalizedText::new("Receipt discarded").with("it", "Ricevuta scartata"),
940            LocalizedText::new("The receipt was thrown away without being recorded.")
941                .with("it", "La ricevuta è stata scartata senza registrare nulla."),
942        ),
943    }
944}
945
946impl PureWorkflow for ClaimWorkflow {
947    fn apply(
948        &self,
949        state: Option<&ClaimState>,
950        command: &ClaimCommand,
951    ) -> Result<Applied<ClaimState, ClaimEvent>, DomainRejection> {
952        apply(state, command)
953    }
954
955    fn event_type(&self, event: &ClaimEvent) -> String {
956        event.event_type().to_owned()
957    }
958}
959
960#[cfg(test)]
961mod tests {
962    use super::*;
963    use crate::workflows::claim::model::{SAMPLE_ATTACHMENT, complete_proposal, under_review};
964
965    #[test]
966    fn a_second_reading_may_not_replace_an_open_proposal() {
967        let state = under_review(complete_proposal());
968        let rejection = validate(
969            Some(&state),
970            &ClaimCommand::ProposeFields {
971                attachment_id: AttachmentId::from(SAMPLE_ATTACHMENT),
972                fields: complete_proposal().fields,
973            },
974        )
975        .unwrap_err();
976        assert_eq!(rejection.code.as_str(), rejection::REVIEW_ALREADY_OPEN);
977    }
978
979    #[test]
980    fn an_incomplete_proposal_cannot_be_accepted_and_offers_no_accept_option() {
981        let partial = Proposal::new(
982            AttachmentId::from(SAMPLE_ATTACHMENT),
983            vec![ProposedField {
984                field: ClaimField::Merchant,
985                value: "Hotel Tejo".into(),
986                edited: false,
987            }],
988        );
989        let state = under_review(partial.clone());
990        assert_eq!(
991            validate(Some(&state), &ClaimCommand::AcceptProposal)
992                .unwrap_err()
993                .code
994                .as_str(),
995            rejection::PROPOSAL_INCOMPLETE
996        );
997        let requirement = review_requirement(&state, &partial);
998        let payload = requirement.payload.expect("the card carries its payload");
999        assert!(payload.option(&ACCEPT_OPTION.into()).is_none());
1000        assert!(payload.option(&ABANDON_OPTION.into()).is_some());
1001        payload
1002            .validate_for(InteractionKind::ReviewChanges)
1003            .expect("a card without an accept option is still answerable");
1004    }
1005
1006    #[test]
1007    fn the_proposal_is_normalized_so_two_readings_of_the_same_values_hash_alike() {
1008        let forwards = complete_proposal();
1009        let mut shuffled = forwards.fields.clone();
1010        shuffled.reverse();
1011        let backwards = Proposal::new(AttachmentId::from(SAMPLE_ATTACHMENT), shuffled);
1012        assert_eq!(forwards, backwards);
1013        assert_eq!(
1014            canonical_digest(&forwards).unwrap(),
1015            canonical_digest(&backwards).unwrap()
1016        );
1017    }
1018
1019    #[test]
1020    fn a_revised_value_is_read_in_the_shape_its_field_takes() {
1021        let revise = OperationKey::from(operations::REVISE_PROPOSED_FIELD);
1022        let compiled =
1023            |arguments: serde_json::Value| ClaimWorkflow::compile_operation(&revise, &arguments);
1024        assert_eq!(
1025            compiled(serde_json::json!({
1026                "field": "total",
1027                "total": { "minor": 130_000, "currency": "EUR" }
1028            }))
1029            .unwrap(),
1030            vec![ClaimCommand::ReviseProposedField {
1031                field: ClaimField::Total,
1032                value: "130000".to_owned(),
1033            }]
1034        );
1035        assert_eq!(
1036            compiled(serde_json::json!({ "field": "receipt_date", "date": "2026-09-01" })).unwrap(),
1037            vec![ClaimCommand::ReviseProposedField {
1038                field: ClaimField::ReceiptDate,
1039                value: "2026-09-01".to_owned(),
1040            }]
1041        );
1042        assert_eq!(
1043            compiled(serde_json::json!({ "field": "merchant", "merchant": "Café Aurora" }))
1044                .unwrap(),
1045            vec![ClaimCommand::ReviseProposedField {
1046                field: ClaimField::Merchant,
1047                value: "Café Aurora".to_owned(),
1048            }]
1049        );
1050        // A total given as words, or no value for the field named, is not a revision.
1051        assert!(compiled(serde_json::json!({ "field": "total", "merchant": "1,300" })).is_err());
1052    }
1053}