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 state_after(
663        &self,
664        state: Option<&ClaimState>,
665        command: &ClaimCommand,
666    ) -> Option<ClaimState> {
667        apply(state, command).ok().map(|applied| applied.state)
668    }
669
670    fn receipts(
671        &self,
672        events: &[ReceiptEvent<ClaimEvent>],
673        locale: &Locale,
674    ) -> Vec<OperationalReceipt> {
675        let _ = locale;
676        events
677            .iter()
678            .map(|event| match event {
679                ReceiptEvent::Committed(committed) => {
680                    let event_ids = vec![committed.event_id];
681                    let status_code = committed.payload.event_type().to_owned();
682                    let (severity, title, body) = receipt_copy(&committed.payload);
683                    OperationalReceipt {
684                        receipt_id: ReceiptId::derive(&event_ids, &status_code),
685                        event_ids,
686                        severity,
687                        title,
688                        body,
689                        status_code,
690                        artifact_refs: Vec::new(),
691                    }
692                }
693                ReceiptEvent::Redacted(redacted) => redacted_receipt(redacted),
694            })
695            .collect()
696    }
697
698    /// Binds the card to the **proposal**, not to the whole state.
699    ///
700    /// The trip sample puts a digest of the entire case in its rebooking card's
701    /// metadata, because the entire case is what that card is about. This card is
702    /// about the proposal, so the proposal is what it hashes — and
703    /// the case revision is deliberately *not* in the metadata, because
704    /// revision binding is already the interaction's own mechanism and putting
705    /// it in the payload would make the payload hash change for every unrelated
706    /// edit.
707    fn build_interaction(
708        &self,
709        state: Option<&ClaimState>,
710        view: &ViewOf<Self>,
711        requirement: &InteractionRequirement,
712    ) -> Result<InteractionSpec, DomainRejection> {
713        let mut spec = requirement.to_spec(view.case_ref.clone());
714        if let Some(proposal) = state.and_then(|state| state.proposal.as_ref()) {
715            let digest =
716                canonical_digest(proposal).map_err(|_| reject(rejection::INVALID_ARGUMENTS))?;
717            spec.payload = spec.payload.with_metadata(serde_json::json!({
718                "proposal_hash": digest.as_str(),
719                "attachment_id": proposal.attachment_id.as_str(),
720            }));
721        }
722        Ok(spec)
723    }
724}
725
726impl ClaimWorkflow {
727    /// The operations a view offers, with their summaries in English.
728    fn offered(view: &ViewOf<Self>) -> Vec<OperationSpec> {
729        let reference = || {
730            operation(
731                operations::SET_REFERENCE,
732                "Set the reference the user types by hand. It is not part of any proposal.",
733            )
734            .arguments::<ReferenceArgs>()
735            .argument("value", |a| {
736                a.label("reference")
737                    .label_in("it-IT", "riferimento")
738                    .required()
739            })
740        };
741        let discard = || {
742            operation(
743                operations::DISCARD_RECEIPT,
744                "Throw the receipt away without recording anything.",
745            )
746        };
747        match view.phase {
748            ClaimPhase::PreDraft => vec![
749                operation(operations::CREATE_DRAFT, "Start an expense claim.")
750                    .target(TargetPolicy::AllowsNewCase),
751            ],
752            ClaimPhase::AwaitingDocument => vec![
753                reference(),
754                operation(
755                    operations::ATTACH_RECEIPT,
756                    "Attach a receipt that arrived with this turn.",
757                )
758                .arguments::<AttachArgs>()
759                .argument("attachment_id", |a| a.written().required()),
760                discard(),
761            ],
762            ClaimPhase::Extracting => vec![reference(), discard()],
763            ClaimPhase::AwaitingReview => vec![
764                // Editing a proposed value and answering the review are two operations.
765                operation(
766                    operations::REVISE_PROPOSED_FIELD,
767                    "Change one value proposed from the receipt. The review stays open.",
768                )
769                .arguments::<ReviseArgs>()
770                .argument("field", |a| {
771                    a.label("field")
772                        .label_in("it-IT", "campo")
773                        .describe("Which proposed value the user names: the merchant, the total or the receipt's date.")
774                        .required()
775                })
776                .argument("merchant", |a| {
777                    a.label("merchant").label_in("it-IT", "esercente")
778                })
779                .argument("total", |a| {
780                    a.label("total").label_in("it-IT", "totale").money()
781                })
782                .argument("date", |a| {
783                    a.label("receipt date")
784                        .label_in("it-IT", "data della ricevuta")
785                        .date_direction(DateDirection::Past)
786                })
787                .example(
788                    "the total is 180 euros",
789                    serde_json::json!({
790                        "field": "total",
791                        "total": { "minor": 18_000, "currency": "EUR" }
792                    }),
793                )
794                .example(
795                    "the merchant is Café Aurora",
796                    serde_json::json!({ "field": "merchant", "merchant": "Café Aurora" }),
797                ),
798                operation(
799                    operations::ACCEPT_PROPOSAL,
800                    "Accept the proposed values: they become the record.",
801                ),
802                operation(
803                    operations::ABANDON_REVIEW,
804                    "Throw the reading away and keep the receipt, so it can be read again.",
805                ),
806                reference(),
807                discard(),
808            ],
809            ClaimPhase::Recorded | ClaimPhase::Discarded => Vec::new(),
810        }
811    }
812}
813
814/// What each operation does, in Italian.
815const ITALIAN: &[(&str, &str)] = &[
816    (operations::CREATE_DRAFT, "Apre una nota spese."),
817    (
818        operations::SET_REFERENCE,
819        "Imposta il riferimento che l'utente scrive a mano. Non fa parte di alcuna proposta.",
820    ),
821    (
822        operations::ATTACH_RECEIPT,
823        "Allega una ricevuta arrivata con questo turno.",
824    ),
825    (
826        operations::REVISE_PROPOSED_FIELD,
827        "Cambia un valore proposto dalla ricevuta. La revisione resta aperta.",
828    ),
829    (
830        operations::ACCEPT_PROPOSAL,
831        "Accetta i valori proposti: diventano il record.",
832    ),
833    (
834        operations::ABANDON_REVIEW,
835        "Scarta la lettura e tiene la ricevuta, così si può leggere di nuovo.",
836    ),
837    (
838        operations::DISCARD_RECEIPT,
839        "Scarta la ricevuta senza registrare nulla.",
840    ),
841];
842
843/// Renders an event whose payload was erased.
844///
845/// The values this domain records were read out of a document a person sent in,
846/// so an erasure request reaches them, and the proposal that quoted them is
847/// exactly the kind of receipt that cannot be re-rendered afterwards. The
848/// erased receipt keeps the one thing that is still true: a step happened, and
849/// it is still in the ledger.
850fn redacted_receipt(redacted: &RedactedEvent) -> OperationalReceipt {
851    let event_ids = vec![redacted.event_id];
852    let status_code = "claim.detail_erased".to_owned();
853    OperationalReceipt {
854        receipt_id: ReceiptId::derive(&event_ids, &status_code),
855        event_ids,
856        severity: ReceiptSeverity::Info,
857        title: LocalizedText::new("Detail erased").with("it", "Dettaglio cancellato"),
858        body: LocalizedText::new(
859            "This step is still on record; the detail of what it recorded was erased.",
860        )
861        .with(
862            "it",
863            "Questo passaggio resta a registro; il dettaglio di cosa ha registrato è stato cancellato.",
864        ),
865        status_code,
866        artifact_refs: Vec::new(),
867    }
868}
869
870/// Server-authored copy, in English with an Italian translation.
871fn receipt_copy(event: &ClaimEvent) -> (ReceiptSeverity, LocalizedText, LocalizedText) {
872    match event {
873        ClaimEvent::DraftCreated => (
874            ReceiptSeverity::Success,
875            LocalizedText::new("Claim opened").with("it", "Nota spese aperta"),
876            LocalizedText::new("A new expense claim is open.")
877                .with("it", "È aperta una nuova nota spese."),
878        ),
879        ClaimEvent::ReferenceSet { value } => (
880            ReceiptSeverity::Success,
881            LocalizedText::new("Reference set").with("it", "Riferimento impostato"),
882            LocalizedText::new(format!("The reference is now \"{value}\"."))
883                .with("it", format!("Il riferimento ora è \"{value}\".")),
884        ),
885        ClaimEvent::ReceiptAttached { attachment_id } => (
886            ReceiptSeverity::Success,
887            LocalizedText::new("Receipt attached").with("it", "Ricevuta allegata"),
888            LocalizedText::new(format!("Receipt {attachment_id} is attached."))
889                .with("it", format!("La ricevuta {attachment_id} è allegata.")),
890        ),
891        ClaimEvent::FieldsProposed { fields, .. } => {
892            let count = fields.len();
893            (
894                ReceiptSeverity::Success,
895                LocalizedText::new("Values proposed").with("it", "Valori proposti"),
896                LocalizedText::new(format!(
897                    "{count} value(s) were read from the receipt and are waiting for review. \
898                     Nothing has been recorded."
899                ))
900                .with(
901                    "it",
902                    format!(
903                        "{count} valore/i sono stati letti dalla ricevuta e attendono conferma. \
904                         Non e stato registrato nulla."
905                    ),
906                ),
907            )
908        }
909        ClaimEvent::ProposedFieldRevised { field, .. } => {
910            let label = field.label();
911            (
912                ReceiptSeverity::Success,
913                LocalizedText::new("Proposed value changed").with("it", "Valore proposto corretto"),
914                LocalizedText::new(format!(
915                    "\"{label}\" now holds what you typed. The review is still open."
916                ))
917                .with(
918                    "it",
919                    format!("\"{label}\" ora contiene quanto hai scritto. La conferma resta aperta."),
920                ),
921            )
922        }
923        ClaimEvent::ProposalAccepted { fields, .. } => {
924            let count = fields.len();
925            (
926                ReceiptSeverity::Success,
927                LocalizedText::new("Values recorded").with("it", "Valori registrati"),
928                LocalizedText::new(format!("{count} value(s) are now on the record."))
929                    .with("it", format!("{count} valore/i ora sono registrati.")),
930            )
931        }
932        ClaimEvent::ProposalAbandoned { attachment_id } => (
933            ReceiptSeverity::Warning,
934            LocalizedText::new("Reading discarded").with("it", "Lettura scartata"),
935            LocalizedText::new(format!(
936                "The reading of receipt {attachment_id} was discarded. The receipt is still attached."
937            ))
938            .with(
939                "it",
940                format!(
941                    "La lettura della ricevuta {attachment_id} è stata scartata. La ricevuta resta allegata."
942                ),
943            ),
944        ),
945        ClaimEvent::ReceiptDiscarded => (
946            ReceiptSeverity::Warning,
947            LocalizedText::new("Receipt discarded").with("it", "Ricevuta scartata"),
948            LocalizedText::new("The receipt was thrown away without being recorded.")
949                .with("it", "La ricevuta è stata scartata senza registrare nulla."),
950        ),
951    }
952}
953
954impl PureWorkflow for ClaimWorkflow {
955    fn apply(
956        &self,
957        state: Option<&ClaimState>,
958        command: &ClaimCommand,
959    ) -> Result<Applied<ClaimState, ClaimEvent>, DomainRejection> {
960        apply(state, command)
961    }
962
963    fn event_type(&self, event: &ClaimEvent) -> String {
964        event.event_type().to_owned()
965    }
966}
967
968#[cfg(test)]
969mod tests {
970    use super::*;
971    use crate::workflows::claim::model::{SAMPLE_ATTACHMENT, complete_proposal, under_review};
972
973    #[test]
974    fn a_second_reading_may_not_replace_an_open_proposal() {
975        let state = under_review(complete_proposal());
976        let rejection = validate(
977            Some(&state),
978            &ClaimCommand::ProposeFields {
979                attachment_id: AttachmentId::from(SAMPLE_ATTACHMENT),
980                fields: complete_proposal().fields,
981            },
982        )
983        .unwrap_err();
984        assert_eq!(rejection.code.as_str(), rejection::REVIEW_ALREADY_OPEN);
985    }
986
987    #[test]
988    fn an_incomplete_proposal_cannot_be_accepted_and_offers_no_accept_option() {
989        let partial = Proposal::new(
990            AttachmentId::from(SAMPLE_ATTACHMENT),
991            vec![ProposedField {
992                field: ClaimField::Merchant,
993                value: "Hotel Tejo".into(),
994                edited: false,
995            }],
996        );
997        let state = under_review(partial.clone());
998        assert_eq!(
999            validate(Some(&state), &ClaimCommand::AcceptProposal)
1000                .unwrap_err()
1001                .code
1002                .as_str(),
1003            rejection::PROPOSAL_INCOMPLETE
1004        );
1005        let requirement = review_requirement(&state, &partial);
1006        let payload = requirement.payload.expect("the card carries its payload");
1007        assert!(payload.option(&ACCEPT_OPTION.into()).is_none());
1008        assert!(payload.option(&ABANDON_OPTION.into()).is_some());
1009        payload
1010            .validate_for(InteractionKind::ReviewChanges)
1011            .expect("a card without an accept option is still answerable");
1012    }
1013
1014    #[test]
1015    fn the_proposal_is_normalized_so_two_readings_of_the_same_values_hash_alike() {
1016        let forwards = complete_proposal();
1017        let mut shuffled = forwards.fields.clone();
1018        shuffled.reverse();
1019        let backwards = Proposal::new(AttachmentId::from(SAMPLE_ATTACHMENT), shuffled);
1020        assert_eq!(forwards, backwards);
1021        assert_eq!(
1022            canonical_digest(&forwards).unwrap(),
1023            canonical_digest(&backwards).unwrap()
1024        );
1025    }
1026
1027    #[test]
1028    fn a_revised_value_is_read_in_the_shape_its_field_takes() {
1029        let revise = OperationKey::from(operations::REVISE_PROPOSED_FIELD);
1030        let compiled =
1031            |arguments: serde_json::Value| ClaimWorkflow::compile_operation(&revise, &arguments);
1032        assert_eq!(
1033            compiled(serde_json::json!({
1034                "field": "total",
1035                "total": { "minor": 130_000, "currency": "EUR" }
1036            }))
1037            .unwrap(),
1038            vec![ClaimCommand::ReviseProposedField {
1039                field: ClaimField::Total,
1040                value: "130000".to_owned(),
1041            }]
1042        );
1043        assert_eq!(
1044            compiled(serde_json::json!({ "field": "receipt_date", "date": "2026-09-01" })).unwrap(),
1045            vec![ClaimCommand::ReviseProposedField {
1046                field: ClaimField::ReceiptDate,
1047                value: "2026-09-01".to_owned(),
1048            }]
1049        );
1050        assert_eq!(
1051            compiled(serde_json::json!({ "field": "merchant", "merchant": "Café Aurora" }))
1052                .unwrap(),
1053            vec![ClaimCommand::ReviseProposedField {
1054                field: ClaimField::Merchant,
1055                value: "Café Aurora".to_owned(),
1056            }]
1057        );
1058        // A total given as words, or no value for the field named, is not a revision.
1059        assert!(compiled(serde_json::json!({ "field": "total", "merchant": "1,300" })).is_err());
1060    }
1061}