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 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 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 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 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
814const 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
843fn 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
870fn 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 assert!(compiled(serde_json::json!({ "field": "total", "merchant": "1,300" })).is_err());
1060 }
1061}