1use 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
36pub mod rejection {
38 pub const NOT_FOUND: &str = "claim.not_found";
40 pub const ALREADY_EXISTS: &str = "claim.already_exists";
42 pub const CLOSED: &str = "claim.closed";
44 pub const REFERENCE_EMPTY: &str = "claim.reference_empty";
46 pub const RECEIPT_ALREADY_ATTACHED: &str = "claim.receipt_already_attached";
48 pub const NO_RECEIPT: &str = "claim.no_receipt";
50 pub const WRONG_RECEIPT: &str = "claim.wrong_receipt";
52 pub const REVIEW_ALREADY_OPEN: &str = "claim.review_already_open";
55 pub const NO_REVIEW: &str = "claim.no_review";
57 pub const PROPOSAL_EMPTY: &str = "claim.proposal_empty";
59 pub const PROPOSAL_DUPLICATE_FIELD: &str = "claim.proposal_duplicate_field";
61 pub const PROPOSED_VALUE_EMPTY: &str = "claim.proposed_value_empty";
63 pub const PROPOSAL_INCOMPLETE: &str = "claim.proposal_incomplete";
65 pub const UNSUPPORTED_ACT: &str = "claim.unsupported_act";
67 pub const UNKNOWN_OPERATION: &str = "claim.unknown_operation";
69 pub const INVALID_ARGUMENTS: &str = "claim.invalid_arguments";
71}
72
73pub const REVIEW_KEY: &str = "claim.proposal_review";
75
76pub const ACCEPT_OPTION: &str = "accept";
78
79pub const ABANDON_OPTION: &str = "abandon";
81
82pub const NOT_NOW_OPTION: &str = "not_now";
84
85pub const PROPOSED_NOTICE: &str = "claim.fields_proposed_from_attachment";
87
88pub const EDITED_NOTICE: &str = "claim.proposal_edited";
90
91pub const ABANDONED_NOTICE: &str = "claim.proposal_abandoned";
93
94pub 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
123pub 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 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
214pub 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 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 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 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#[derive(Debug, Clone, Copy, Default)]
327#[non_exhaustive]
328pub struct ClaimWorkflow;
329
330impl ClaimWorkflow {
331 #[must_use]
333 pub const fn new() -> Self {
334 Self
335 }
336
337 #[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 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 _ => 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
393fn 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
412fn 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 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 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 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 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 ClaimCommand::ProposeFields { .. } => CommandPolicy {
640 risk: RiskClass::ReversibleLowRisk,
641 confirmation: ConfirmationPolicy::None,
642 atomicity: AtomicityScope::PerCase,
643 claim_mode: ClaimMode::ServerReceiptOnly,
644 },
645 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 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 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 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
806const 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
835fn 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
862fn 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 assert!(compiled(serde_json::json!({ "field": "total", "merchant": "1,300" })).is_err());
1052 }
1053}