1use serde::de::DeserializeOwned;
10use turnframe_core::case::CaseRef;
11use turnframe_core::command::{
12 AtomicityScope, ClaimMode, CommandPolicy, ConfirmationPolicy, RiskClass,
13};
14use turnframe_core::error::DomainRejection;
15use turnframe_core::event::{
16 ArtifactRef, CommittedEvent, OperationalReceipt, ReceiptEvent, ReceiptSeverity, RedactedEvent,
17};
18use turnframe_core::flow::{
19 ConfirmationSubject, DomainEnumeration, EnumeratedValue, InteractionRequirement,
20 PhaseOwnership, StateField, ViewOf, WorkflowDefinition, WorkflowNotice, WorkflowView,
21};
22use turnframe_core::hash::canonical_digest;
23use turnframe_core::ids::{OperationKey, ReceiptId, WorkflowKey, WorkflowVersion};
24use turnframe_core::interaction::{
25 InteractionKind, InteractionOption, InteractionPayload, InteractionSpec, OptionStyle,
26 StoredInteractionAction,
27};
28use turnframe_core::locale::{Locale, LocalizedText};
29use turnframe_core::operation::{DateDirection, GlossaryTerm, OperationSpec};
30use turnframe_core::plan::TargetPolicy;
31use turnframe_core::response::NoticeSeverity;
32use turnframe_core::target::{ResolvedAct, ResolvedActKind};
33
34use crate::workflows::trip::apply::{apply, reject, rejection, validate};
35use crate::workflows::trip::command::{
36 AddExtraArgs, AssignPayerArgs, ChangeExtraArgs, ChangeTravelerArgs, LegArgs, OpenArgs,
37 SetNameArgs, SetTravelDateArgs, SetTravelerArgs, TravelerRecord, TripCommand, TripEvent,
38 operations,
39};
40use crate::workflows::trip::state::{
41 NewExtra, Payer, TripObligation, TripOutcome, TripPhase, TripState, TripStatus, TripTraveler,
42};
43use crate::workflows::{Applied, PureWorkflow};
44
45pub const REBOOKING_CONFIRMATION_KEY: &str = "trip.rebooking_confirmation";
47
48pub const REBOOK_CONFIRM_OPTION: &str = "confirm";
50
51pub const REBOOK_DECLINE_OPTION: &str = "decline";
53
54#[derive(Debug, Clone, Copy, Default)]
56#[non_exhaustive]
57pub struct TripWorkflow {
58 with_cards: bool,
59}
60
61impl TripWorkflow {
62 #[must_use]
64 pub const fn new() -> Self {
65 Self { with_cards: false }
66 }
67
68 #[must_use]
72 pub const fn with_cards(mut self) -> Self {
73 self.with_cards = true;
74 self
75 }
76
77 #[must_use]
79 pub fn phase_of(state: &TripState) -> TripPhase {
80 match state.status {
81 TripStatus::Draft => TripPhase::Collecting,
82 TripStatus::AwaitingRebookingConfirmation => TripPhase::AwaitingRebookingConfirmation,
83 TripStatus::Rebooking => TripPhase::Dispatching,
84 TripStatus::Ticketed => TripPhase::Ticketed,
85 TripStatus::Refused => TripPhase::Refused,
86 TripStatus::Notified => TripPhase::Notified,
87 TripStatus::NotNotified => TripPhase::NotNotified,
88 TripStatus::Withdrawn => TripPhase::Withdrawn,
89 }
90 }
91
92 fn compile_operation(
93 state: Option<&TripState>,
94 operation: &OperationKey,
95 arguments: &serde_json::Value,
96 ) -> Result<Vec<TripCommand>, DomainRejection> {
97 let command = match operation.as_str() {
98 operations::OPEN => {
99 let args: OpenArgs = if arguments.is_null() {
100 OpenArgs::default()
101 } else {
102 parse(arguments)?
103 };
104 match args.traveler {
105 Some(record) => TripCommand::OpenFor {
106 traveler: traveler_of(record),
107 },
108 None => TripCommand::Open,
109 }
110 }
111 operations::SET_NAME => {
112 let value = parse::<SetNameArgs>(arguments)?.value;
113 if value.trim().is_empty() {
116 return Err(reject(rejection::EMPTY_NAME).with_explanation(
117 LocalizedText::new("A trip name cannot be empty.").with(
118 Locale::from("it-IT"),
119 "Il nome del viaggio non può essere vuoto.",
120 ),
121 ));
122 }
123 if state.is_some_and(|state| state.name.as_deref() == Some(value.as_str())) {
126 return Ok(Vec::new());
127 }
128 TripCommand::SetName { value }
129 }
130 operations::SET_TRAVEL_DATE => TripCommand::SetTravelDate {
131 value: parse::<SetTravelDateArgs>(arguments)?.value,
132 },
133 operations::ADD_EXTRA => {
134 let args = parse::<AddExtraArgs>(arguments)?;
135 if args.unit_price.currency != "EUR" {
136 return Err(reject(rejection::CURRENCY_NOT_ACCEPTED)
137 .on_argument("/unit_price")
138 .with_explanation(
139 LocalizedText::new("Extras on this desk are in euros.")
140 .with(Locale::from("it-IT"), "Gli extra qui sono in euro."),
141 ));
142 }
143 TripCommand::AddExtra {
144 extra: NewExtra {
145 description: args.description,
146 quantity: args.quantity,
147 unit_price_cents: args.unit_price.minor,
148 },
149 }
150 }
151 operations::ASSIGN_PAYER => {
152 let args = parse::<AssignPayerArgs>(arguments)?;
153 TripCommand::AssignPayer {
154 extra_id: numbered_extra(state, args.extra)?,
155 payer: args.payer,
156 }
157 }
158 operations::CHANGE_EXTRA => {
159 let args = parse::<ChangeExtraArgs>(arguments)?;
160 let extra_id = numbered_extra(state, args.extra)?;
161 if args.description.is_none()
163 && args.quantity.is_none()
164 && args.unit_price.is_none()
165 {
166 return Ok(Vec::new());
167 }
168 TripCommand::ChangeExtra {
169 extra_id,
170 description: args.description,
171 quantity: args.quantity,
172 unit_price_cents: args.unit_price.map(|price| price.minor),
173 }
174 }
175 operations::CHANGE_TRAVELER => {
176 let traveler = parse::<ChangeTravelerArgs>(arguments)?.traveler;
177 TripCommand::ChangeTraveler {
178 traveler: TripTraveler {
179 traveler_id: turnframe_core::hash::derive_uuid(
180 "turnframe.sample.trip.traveler",
181 &[traveler.trim()],
182 ),
183 display_name: traveler.trim().to_owned(),
184 },
185 }
186 }
187 operations::SET_TRAVELER => TripCommand::ChangeTraveler {
188 traveler: traveler_of(parse::<SetTravelerArgs>(arguments)?.traveler),
189 },
190 operations::PROTECT_LEG => TripCommand::ProtectLeg {
191 leg: parse::<LegArgs>(arguments)?.leg,
192 },
193 operations::REQUEST_REBOOKING => TripCommand::RequestRebooking {
194 leg: parse::<LegArgs>(arguments)?.leg,
195 },
196 operations::REBOOK => TripCommand::Rebook,
197 operations::WITHDRAW => TripCommand::Withdraw,
198 operations::ACKNOWLEDGE_CARD => return Ok(Vec::new()),
201 _ => return Err(reject(turnframe_core::error::UNKNOWN_OPERATION)),
204 };
205 Ok(vec![command])
206 }
207}
208
209fn parse<T: DeserializeOwned>(arguments: &serde_json::Value) -> Result<T, DomainRejection> {
210 serde_json::from_value(arguments.clone()).map_err(|_| reject(rejection::INVALID_ARGUMENTS))
211}
212
213fn numbered_extra(state: Option<&TripState>, number: u32) -> Result<uuid::Uuid, DomainRejection> {
215 number
216 .checked_sub(1)
217 .and_then(|index| state?.extras.get(usize::try_from(index).ok()?))
218 .map(|extra| extra.extra_id)
219 .ok_or_else(|| {
220 reject(rejection::UNKNOWN_EXTRA)
221 .on_argument("/extra")
222 .with_explanation(
223 LocalizedText::new("This trip has no extra with that number.").with(
224 Locale::from("it-IT"),
225 "Questo viaggio non ha un extra con quel numero.",
226 ),
227 )
228 })
229}
230
231fn operation(key: &str, summary: &str, target: TargetPolicy) -> OperationSpec {
232 OperationSpec::new(key)
233 .summary(summary)
234 .target(target)
235 .mutating()
236}
237
238fn leg_argument(spec: OperationSpec) -> OperationSpec {
240 spec.arguments::<LegArgs>().argument("leg", |a| {
241 a.label("leg number")
242 .label_in("it-IT", "numero della tratta")
243 .describe("The leg's number as the record lists it: the outbound is 1.")
244 .required()
245 .inferred()
246 })
247}
248
249fn editing_operations() -> Vec<OperationSpec> {
253 let existing = TargetPolicy::RequiresExistingCase;
254 vec![
255 operation(
256 operations::SET_NAME,
257 "Name the trip: what the traveler calls it, in a few words.",
258 existing,
259 )
260 .arguments::<SetNameArgs>()
261 .argument("value", |a| {
262 a.label("trip name")
263 .label_in("it-IT", "nome del viaggio")
264 .required()
265 })
266 .example(
267 "the trip is for the Lisbon offsite",
268 serde_json::json!({ "value": "Lisbon offsite" }),
269 )
270 .example(
271 "chiamalo offsite Lisbona",
272 serde_json::json!({ "value": "offsite Lisbona" }),
273 )
274 .example_not_given("the trip name needs changing", ["value"])
275 .example_not_given("can I name the trip?", ["value"]),
276 operation(
277 operations::SET_TRAVEL_DATE,
278 "Set the day the traveler would rather fly.",
279 existing,
280 )
281 .arguments::<SetTravelDateArgs>()
282 .argument("value", |a| {
283 a.label("travel date")
284 .label_in("it-IT", "data del viaggio")
285 .required()
286 .date_direction(DateDirection::Future)
287 })
288 .example_not_given("I want to change the travel date", ["value"]),
289 operation(
290 operations::ADD_EXTRA,
291 "Add one extra to the trip, what it is, how many and at what price; each extra is added on its own.",
292 existing,
293 )
294 .arguments::<AddExtraArgs>()
295 .argument("description", |a| {
296 a.label("description")
297 .label_in("it-IT", "descrizione")
298 .required()
299 })
300 .argument("quantity", |a| {
301 a.label("quantity")
302 .label_in("it-IT", "quantità")
303 .describe("How many: the count this extra's own words give; «an extra» and «a bag» are one.")
304 .required()
305 .inferred()
306 })
307 .argument("unit_price", |a| {
308 a.label("unit price")
309 .label_in("it-IT", "prezzo unitario")
310 .required()
311 .money()
312 })
313 .example(
314 "40 euros for a checked bag",
315 serde_json::json!({
316 "description": "checked bag",
317 "quantity": 1,
318 "unit_price": { "minor": 4000, "currency": "EUR" }
319 }),
320 )
321 .example(
322 "2 airport meals at 15 euros each",
323 serde_json::json!({
324 "description": "airport meals",
325 "quantity": 2,
326 "unit_price": { "minor": 1500, "currency": "EUR" }
327 }),
328 ),
329 operation(
330 operations::ASSIGN_PAYER,
331 "Say who pays for one extra: the traveler, the company, or the airline.",
332 existing,
333 )
334 .arguments::<AssignPayerArgs>()
335 .argument("extra", |a| {
336 a.label("extra number")
337 .label_in("it-IT", "numero dell'extra")
338 .describe("The extra's number as the record lists it.")
339 .required()
340 .inferred()
341 })
342 .argument("payer", |a| {
343 a.label("who pays").label_in("it-IT", "chi paga").required()
344 }),
345 operation(
346 operations::CHANGE_EXTRA,
347 "Change an extra already on the trip: its description, quantity or unit price.",
348 existing,
349 )
350 .arguments::<ChangeExtraArgs>()
351 .argument("extra", |a| {
352 a.label("extra number")
353 .label_in("it-IT", "numero dell'extra")
354 .describe("The extra's number as the record lists it.")
355 .required()
356 .inferred()
357 })
358 .argument("description", |a| {
359 a.label("description").label_in("it-IT", "descrizione")
360 })
361 .argument("quantity", |a| {
362 a.label("quantity").label_in("it-IT", "quantità")
363 })
364 .argument("unit_price", |a| {
365 a.label("unit price")
366 .label_in("it-IT", "prezzo unitario")
367 .money()
368 })
369 .example(
370 "extra 2 is actually 3 bags",
371 serde_json::json!({ "extra": 2, "quantity": 3 }),
372 ),
373 operation(
374 operations::CHANGE_TRAVELER,
375 "Put another traveler on the trip.",
376 existing,
377 )
378 .arguments::<ChangeTravelerArgs>()
379 .argument("traveler", |a| {
380 a.label("traveler")
381 .label_in("it-IT", "viaggiatore")
382 .required()
383 })
384 .example(
385 "Tom Becker is flying instead",
386 serde_json::json!({ "traveler": "Tom Becker" }),
387 ),
388 leg_argument(operation(
389 operations::PROTECT_LEG,
390 "Lock one leg of the booking so nothing changes it, asked on its own; «don't touch the return» said while the outbound changes is a condition of the turn, not a lock.",
391 existing,
392 ))
393 .example(
394 "don't touch the return flight",
395 serde_json::json!({ "leg": 2 }),
396 ),
397 ]
398}
399
400fn sentence_end(name: &str) -> &str {
402 name.trim_end_matches('.')
403}
404
405fn traveler_of(record: TravelerRecord) -> TripTraveler {
407 TripTraveler {
408 traveler_id: turnframe_core::hash::derive_uuid(
409 "turnframe.sample.trip.traveler_record",
410 &[record.case_id.as_str()],
411 ),
412 display_name: record
414 .label
415 .unwrap_or_else(|| "the new traveler".to_owned()),
416 }
417}
418
419fn set_traveler() -> OperationSpec {
421 operation(
422 operations::SET_TRAVELER,
423 "Choose the traveler the trip is for.",
424 TargetPolicy::RequiresExistingCase,
425 )
426 .arguments::<SetTravelerArgs>()
427 .argument("traveler", |a| {
428 a.label("traveler")
429 .label_in("it-IT", "viaggiatore")
430 .required()
431 .record("traveler")
432 })
433}
434
435fn money(cents: i64, language: &str) -> String {
437 let sign = if cents < 0 { "-" } else { "" };
438 let (whole, part) = (cents.unsigned_abs() / 100, cents.unsigned_abs() % 100);
439 match language {
440 "it" => format!("{sign}{whole},{part:02} €"),
441 _ => format!("{sign}€{whole}.{part:02}"),
442 }
443}
444
445fn payer_name(payer: Payer, language: &str) -> &'static str {
447 let italian = language == "it";
448 match payer {
449 Payer::Traveler if italian => "il viaggiatore",
450 Payer::Traveler => "the traveler",
451 Payer::Company if italian => "l'azienda",
452 Payer::Company => "the company",
453 Payer::Airline if italian => "la compagnia aerea",
454 Payer::Airline => "the airline",
455 }
456}
457
458fn rebooking_card_body(state: &TripState) -> LocalizedText {
460 let Some(offer) = &state.offer else {
461 return LocalizedText::new("No rebooking is quoted.")
462 .with("it", "Nessun cambio è proposto.");
463 };
464 let route = state
465 .leg(offer.leg)
466 .map_or_else(String::new, |leg| format!(" ({}→{})", leg.from, leg.to));
467 let fare = offer.fare_difference_cents;
468 LocalizedText::new(format!(
469 "Leg {}{route} on {}, {}, {} more. Once sent, the airline decides.",
470 offer.leg,
471 offer.flight,
472 offer.departs,
473 money(fare, "en")
474 ))
475 .with(
476 "it",
477 format!(
478 "Tratta {}{route} sul volo {}, {}, {} in più. Una volta inviato, decide la \
479 compagnia aerea.",
480 offer.leg,
481 offer.flight,
482 offer.departs,
483 money(fare, "it")
484 ),
485 )
486}
487
488fn rebooking_requirement(state: &TripState) -> InteractionRequirement {
490 let payload = InteractionPayload::new(
491 LocalizedText::new("Rebook this flight?").with("it", "Cambio questo volo?"),
492 )
493 .with_body(rebooking_card_body(state))
494 .with_option(
495 InteractionOption::new(
496 REBOOK_CONFIRM_OPTION,
497 LocalizedText::new("Confirm").with("it", "Conferma"),
498 StoredInteractionAction::ApplyOperation {
499 operation: OperationKey::from(operations::REBOOK),
500 arguments: serde_json::Value::Null,
501 freeform_argument: None,
502 },
503 )
504 .with_style(OptionStyle::Primary),
505 )
506 .with_option(
507 InteractionOption::new(
508 REBOOK_DECLINE_OPTION,
509 LocalizedText::new("Keep my flight").with("it", "Tengo il mio volo"),
510 StoredInteractionAction::DeclineAndRecord {
514 operation: OperationKey::from(operations::ACKNOWLEDGE_CARD),
515 },
516 )
517 .with_style(OptionStyle::Danger),
518 );
519 InteractionRequirement::blocking(REBOOKING_CONFIRMATION_KEY, InteractionKind::ConfirmCommand)
520 .with_confirms_risk(RiskClass::ExternalRegulated)
521 .with_payload(payload)
522}
523
524fn airline_notice(state: &TripState) -> Option<WorkflowNotice> {
525 let status = state.external_status?;
526 Some(WorkflowNotice {
527 code: "trip.airline_status".to_owned(),
528 severity: NoticeSeverity::Info,
529 text: LocalizedText::new(format!("Airline status: {status:?}.")).with(
530 "it",
531 format!("Stato presso la compagnia aerea: {status:?}."),
532 ),
533 })
534}
535
536fn refusal_notice(state: &TripState) -> WorkflowNotice {
537 let code = state.refusal_code.as_deref().unwrap_or("unknown");
538 WorkflowNotice {
539 code: "trip.refused_by_airline".to_owned(),
540 severity: NoticeSeverity::Warning,
541 text: LocalizedText::new(format!(
542 "The airline refused the rebooking (code {code}). Another can be asked for."
543 ))
544 .with(
545 "it",
546 format!(
547 "La compagnia aerea ha rifiutato il cambio (codice {code}). Se ne può chiedere \
548 un altro."
549 ),
550 ),
551 }
552}
553
554impl WorkflowDefinition for TripWorkflow {
555 type State = TripState;
556 type Phase = TripPhase;
557 type Obligation = TripObligation;
558 type Command = TripCommand;
559 type Event = TripEvent;
560 type Outcome = TripOutcome;
561
562 fn key(&self) -> WorkflowKey {
563 WorkflowKey::from("trip")
564 }
565
566 fn version(&self) -> WorkflowVersion {
567 WorkflowVersion::from("1")
568 }
569
570 fn phase_ownership(&self, phase: &TripPhase) -> PhaseOwnership {
571 match phase {
572 TripPhase::PreDraft | TripPhase::Collecting | TripPhase::Refused => {
573 PhaseOwnership::System
574 }
575 TripPhase::AwaitingRebookingConfirmation => PhaseOwnership::User,
576 TripPhase::Dispatching | TripPhase::Ticketed => PhaseOwnership::External,
577 TripPhase::Notified | TripPhase::NotNotified | TripPhase::Withdrawn => {
578 PhaseOwnership::Terminal
579 }
580 }
581 }
582
583 fn obligation_act(
586 &self,
587 state: Option<&TripState>,
588 obligation: &TripObligation,
589 ) -> Option<turnframe_core::flow::ObligationAct> {
590 let TripObligation::AssignPayer { extra_id } = obligation else {
591 return None;
592 };
593 let position = state?
594 .extras
595 .iter()
596 .position(|extra| extra.extra_id == *extra_id)?;
597 Some(
598 turnframe_core::flow::ObligationAct::new(operations::ASSIGN_PAYER, ["payer"])
599 .given("extra", serde_json::json!(position + 1)),
600 )
601 }
602
603 fn obligation_sentence(&self, obligation: &TripObligation) -> Option<LocalizedText> {
604 let it = Locale::from("it-IT");
605 Some(match obligation {
606 TripObligation::SelectTraveler => {
607 LocalizedText::new("Who is travelling?").with(it, "Chi viaggia?")
608 }
609 TripObligation::AssignPayer { .. } => {
610 LocalizedText::new("An extra has no payer yet: who pays for it?")
611 .with(it, "Un extra non ha ancora chi lo paga: chi lo paga?")
612 }
613 TripObligation::SetName => LocalizedText::new("What should I call this trip?")
614 .with(it, "Come chiamo questo viaggio?"),
615 TripObligation::SetTravelDate => LocalizedText::new("Which day would you rather fly?")
616 .with(it, "In che giorno preferisci volare?"),
617 })
618 }
619
620 fn narratable_state(&self, state: Option<&TripState>) -> Vec<StateField> {
623 let Some(state) = state else {
624 return Vec::new();
625 };
626 let mut held = Vec::new();
627 if let Some(traveler) = &state.traveler {
628 held.push(
629 StateField::new("traveler", serde_json::json!(traveler.display_name)).identifying(),
630 );
631 }
632 if let Some(name) = &state.name {
633 held.push(StateField::new("name", serde_json::json!(name)).identifying());
634 }
635 if let Some(date) = state.travel_date {
636 held.push(StateField::new(
637 "travel_date",
638 serde_json::json!(date.to_string()),
639 ));
640 }
641 if !state.legs.is_empty() {
642 let legs: Vec<String> = state
643 .legs
644 .iter()
645 .map(|leg| {
646 let kept = if leg.protected { ", kept as it is" } else { "" };
647 format!(
648 "{}. {} {}→{} {}, {:?}{kept}",
649 leg.number, leg.flight, leg.from, leg.to, leg.departs, leg.status
650 )
651 })
652 .collect();
653 held.push(StateField::new("legs", serde_json::json!(legs)));
654 }
655 if !state.extras.is_empty() {
656 let extras: Vec<String> = state
657 .extras
658 .iter()
659 .enumerate()
660 .map(|(index, extra)| {
661 let payer = extra
662 .payer
663 .map_or_else(|| "no payer yet".to_owned(), |p| format!("paid by {p:?}"));
664 format!(
665 "{}. {} × {} at {}.{:02} EUR, {payer}",
666 index + 1,
667 extra.quantity,
668 extra.description,
669 extra.unit_price_cents / 100,
670 extra.unit_price_cents.rem_euclid(100)
671 )
672 })
673 .collect();
674 held.push(StateField::new("extras", serde_json::json!(extras)));
675 }
676 if let Some(offer) = &state.offer {
677 held.push(StateField::new(
678 "offer",
679 serde_json::json!(format!(
680 "leg {} on {} {}, {}.{:02} EUR more",
681 offer.leg,
682 offer.flight,
683 offer.departs,
684 offer.fare_difference_cents / 100,
685 offer.fare_difference_cents.rem_euclid(100)
686 )),
687 ));
688 }
689 held
690 }
691
692 fn project(&self, case_ref: CaseRef, state: Option<&TripState>) -> ViewOf<Self> {
693 let version = self.version();
694 let Some(state) = state else {
695 return WorkflowView::new(case_ref, version, TripPhase::PreDraft);
696 };
697 let phase = Self::phase_of(state);
698 let view =
699 WorkflowView::new(case_ref, version, phase).with_obligations(state.open_obligations());
700 match phase {
701 TripPhase::AwaitingRebookingConfirmation => {
702 view.with_blocking_interaction(rebooking_requirement(state))
703 }
704 TripPhase::Refused => view.with_notice(refusal_notice(state)),
705 TripPhase::Dispatching | TripPhase::Ticketed => match airline_notice(state) {
706 Some(notice) => view.with_notice(notice),
707 None => view,
708 },
709 TripPhase::Withdrawn => view.with_outcome(TripOutcome::Withdrawn),
710 TripPhase::Notified => view.with_outcome(TripOutcome::Notified),
711 TripPhase::NotNotified => view.with_outcome(TripOutcome::NotNotified),
712 TripPhase::PreDraft | TripPhase::Collecting => view,
713 }
714 }
715
716 fn summary(&self) -> Option<String> {
717 Some(String::from(
718 "Trips: the disruption case of a booking, from its extras to a rebooking.",
719 ))
720 }
721
722 fn noun(&self) -> Option<LocalizedText> {
723 Some(LocalizedText::new("trip").with("it", "viaggio"))
724 }
725
726 fn glossary(&self) -> Vec<GlossaryTerm> {
727 vec![GlossaryTerm::new(
728 "fare difference",
729 "what the new flight costs beyond the ticket already paid",
730 )]
731 }
732
733 fn operations(&self, view: &ViewOf<Self>) -> Vec<OperationSpec> {
734 super::super::in_italian(Self::offered(view), ITALIAN)
735 }
736
737 fn briefing(&self, view: &ViewOf<Self>) -> Option<String> {
741 match view.phase {
742 TripPhase::Collecting => Some(String::from(
743 "A leg marked as kept is one the traveler asked to leave as it is: never \
744 propose changing it. A field that is not open as an obligation has already \
745 been settled; do not ask for it again.",
746 )),
747 TripPhase::AwaitingRebookingConfirmation => Some(String::from(
748 "This trip is waiting on the rebooking card. Read the message as an answer to \
749 that card, not as a new instruction, unless it plainly changes a field.",
750 )),
751 _ => None,
752 }
753 }
754
755 fn transition_briefing(&self, view: &ViewOf<Self>) -> Option<String> {
757 match view.phase {
758 TripPhase::PreDraft => Some(String::from(
759 "There is no case yet. What is needed first is who is travelling: ask for the \
760 traveler and nothing else.",
761 )),
762 TripPhase::Collecting => Some(String::from(
763 "Ask for one thing at a time, in the order the obligations are listed. Do not \
764 read the trip back unless the user asked for it.",
765 )),
766 TripPhase::AwaitingRebookingConfirmation => Some(String::from(
767 "The rebooking card below your text already shows the flight and the fare. Do \
768 not repeat them.",
769 )),
770 _ => None,
771 }
772 }
773
774 fn answer_briefing(&self, view: &ViewOf<Self>) -> Option<String> {
776 match view.phase {
777 TripPhase::Collecting => Some(String::from(
778 "Amounts on this trip are in whole cents; say them as currency.",
779 )),
780 _ => None,
781 }
782 }
783
784 fn artifacts(&self, view: &ViewOf<Self>) -> Vec<ArtifactRef> {
787 if !matches!(
788 view.phase,
789 TripPhase::AwaitingRebookingConfirmation | TripPhase::Ticketed
790 ) {
791 return Vec::new();
792 }
793 vec![ArtifactRef {
794 artifact_id: format!("itinerary:{}", view.case_ref.case_id),
795 kind: "itinerary_pdf".to_owned(),
796 label: LocalizedText::new("Itinerary preview").with("it", "Anteprima itinerario"),
797 uri: None,
798 media_type: Some("application/pdf".to_owned()),
799 }]
800 }
801
802 fn confirmation_subject(
804 &self,
805 state: Option<&TripState>,
806 _view: &ViewOf<Self>,
807 act: &ResolvedAct,
808 ) -> Option<ConfirmationSubject> {
809 let ResolvedActKind::ApplyOperation { operation } = &act.kind else {
810 return None;
811 };
812 if operation.as_str() != operations::WITHDRAW {
813 return None;
814 }
815 let who = state.and_then(|state| state.traveler.as_ref()).map_or_else(
816 || String::from("this case"),
817 |traveler| format!("the case for {}", traveler.display_name),
818 );
819 Some(ConfirmationSubject::asking(LocalizedText::new(format!(
820 "Withdraw {who}?"
821 ))))
822 }
823
824 fn enumerations(&self, _view: &ViewOf<Self>) -> Vec<DomainEnumeration> {
826 vec![
827 DomainEnumeration::new(
828 "extras.payer",
829 vec![
830 EnumeratedValue::new("traveler", LocalizedText::new("The traveler")),
831 EnumeratedValue::new("company", LocalizedText::new("The company")),
832 EnumeratedValue::new("airline", LocalizedText::new("The airline")),
833 ],
834 )
835 .with_preamble(LocalizedText::new("An extra can be paid by one of these.")),
836 ]
837 }
838
839 fn next_steps(&self, view: &ViewOf<Self>) -> Vec<LocalizedText> {
843 if view.phase != TripPhase::Collecting || !view.obligations.is_empty() {
844 return Vec::new();
845 }
846 vec![LocalizedText::new("Add another extra.").with("it", "Aggiungere un altro extra.")]
847 }
848
849 fn compile_act(
850 &self,
851 state: Option<&TripState>,
852 _view: &ViewOf<Self>,
853 act: &ResolvedAct,
854 ) -> Result<Vec<TripCommand>, DomainRejection> {
855 match &act.kind {
856 ResolvedActKind::StartWorkflow => Ok(vec![TripCommand::Open]),
857 ResolvedActKind::ApplyOperation { operation } => {
858 Self::compile_operation(state, operation, &act.arguments)
859 }
860 _ => Err(reject(rejection::UNSUPPORTED_ACT)),
861 }
862 }
863
864 fn nothing_changed(
866 &self,
867 state: Option<&TripState>,
868 act: &ResolvedAct,
869 ) -> Option<LocalizedText> {
870 let ResolvedActKind::ApplyOperation { operation } = &act.kind else {
871 return None;
872 };
873 if operation.as_str() != operations::SET_NAME {
874 return None;
875 }
876 let name = state?.name.as_deref()?;
877 Some(
878 LocalizedText::new(format!("The trip is already called \"{name}\".")).with(
879 Locale::from("it-IT"),
880 format!("Il viaggio si chiama già «{name}»."),
881 ),
882 )
883 }
884
885 fn command_policy(&self, state: Option<&TripState>, command: &TripCommand) -> CommandPolicy {
886 match command {
887 TripCommand::ChangeTraveler { .. }
889 if !self.with_cards || state.is_some_and(|state| state.traveler.is_none()) =>
890 {
891 CommandPolicy::low_risk()
892 }
893 TripCommand::Withdraw if !self.with_cards => CommandPolicy::low_risk(),
894 TripCommand::Rebook => CommandPolicy {
895 risk: RiskClass::ExternalRegulated,
896 confirmation: ConfirmationPolicy::ExplicitClick,
897 atomicity: AtomicityScope::ExternalSaga {
898 saga: "trip.rebooking".to_owned(),
899 },
900 claim_mode: ClaimMode::ServerReceiptOnly,
901 },
902 TripCommand::Withdraw => CommandPolicy {
903 risk: RiskClass::Destructive,
904 confirmation: ConfirmationPolicy::ExplicitClick,
905 atomicity: AtomicityScope::PerCase,
906 claim_mode: ClaimMode::ServerReceiptOnly,
907 },
908 TripCommand::ChangeTraveler { .. } => CommandPolicy {
909 risk: RiskClass::SensitiveDataChange,
910 confirmation: ConfirmationPolicy::ReviewCard,
911 atomicity: AtomicityScope::PerCase,
912 claim_mode: ClaimMode::EventReferencedParaphrase,
913 },
914 TripCommand::RecordAirlineOutcome { .. } => CommandPolicy {
915 risk: RiskClass::ExternalRegulated,
916 confirmation: ConfirmationPolicy::None,
917 atomicity: AtomicityScope::PerCase,
918 claim_mode: ClaimMode::ServerReceiptOnly,
919 },
920 TripCommand::Open
921 | TripCommand::OpenFor { .. }
922 | TripCommand::SetName { .. }
923 | TripCommand::SetTravelDate { .. }
924 | TripCommand::AddExtra { .. }
925 | TripCommand::AssignPayer { .. }
926 | TripCommand::ChangeExtra { .. }
927 | TripCommand::ProtectLeg { .. }
928 | TripCommand::Requote { .. }
929 | TripCommand::RequestRebooking { .. } => CommandPolicy::low_risk(),
930 }
931 }
932
933 fn validate_command(
934 &self,
935 state: Option<&TripState>,
936 command: &TripCommand,
937 ) -> Result<(), DomainRejection> {
938 validate(state, command)
939 }
940
941 fn receipts(
942 &self,
943 events: &[ReceiptEvent<TripEvent>],
944 locale: &Locale,
945 ) -> Vec<OperationalReceipt> {
946 let _ = locale;
947 events
948 .iter()
949 .map(|event| match event {
950 ReceiptEvent::Committed(committed) => committed_receipt(committed),
951 ReceiptEvent::Redacted(redacted) => redacted_receipt(redacted),
952 })
953 .collect()
954 }
955
956 fn build_interaction(
957 &self,
958 state: Option<&TripState>,
959 view: &ViewOf<Self>,
960 requirement: &InteractionRequirement,
961 ) -> Result<InteractionSpec, DomainRejection> {
962 let mut spec = requirement.to_spec(view.case_ref.clone());
963 if let Some(state) = state {
964 let preview =
966 canonical_digest(state).map_err(|_| reject(rejection::INVALID_ARGUMENTS))?;
967 spec.payload = spec.payload.with_metadata(serde_json::json!({
968 "preview_hash": preview.as_str(),
969 "bound_revision": view.case_ref.expected_revision.value(),
970 }));
971 }
972 Ok(spec)
973 }
974}
975
976impl TripWorkflow {
977 fn offered(view: &ViewOf<Self>) -> Vec<OperationSpec> {
979 let withdraw = operation(
982 operations::WITHDRAW,
983 "Withdraw the case before a rebooking is sent.",
984 TargetPolicy::RequiresCatalogedCase,
985 );
986 let request_rebooking = leg_argument(operation(
987 operations::REQUEST_REBOOKING,
988 "Show the rebooking card for the flight the airline quoted for a leg.",
989 TargetPolicy::RequiresExistingCase,
990 ));
991 match view.phase {
992 TripPhase::PreDraft => vec![
993 operation(
994 operations::OPEN,
995 "Open a new trip, the disruption case of one booking, for its traveler when named.",
996 TargetPolicy::NewCaseOnly,
997 )
998 .arguments::<OpenArgs>()
999 .argument("traveler", |a| {
1000 a.label("traveler")
1001 .label_in("it-IT", "viaggiatore")
1002 .record("traveler")
1003 }),
1004 ],
1005 TripPhase::Collecting | TripPhase::Refused => {
1006 let mut offered = editing_operations();
1007 if view.obligations.contains(&TripObligation::SelectTraveler) {
1010 offered.retain(|spec| spec.key.as_str() != operations::CHANGE_TRAVELER);
1011 offered.push(set_traveler());
1012 }
1013 offered.push(request_rebooking);
1014 offered.push(withdraw);
1015 offered
1016 }
1017 TripPhase::AwaitingRebookingConfirmation => {
1018 let mut offered = editing_operations();
1019 offered.push(
1022 operation(
1023 operations::REBOOK,
1024 "Send the rebooking to the airline.",
1025 TargetPolicy::RequiresCatalogedCase,
1026 )
1027 .card_only(),
1028 );
1029 offered.push(
1032 operation(
1033 operations::ACKNOWLEDGE_CARD,
1034 "Acknowledge the card currently on screen.",
1035 TargetPolicy::RequiresCatalogedCase,
1036 )
1037 .card_only(),
1038 );
1039 offered.push(withdraw);
1040 offered
1041 }
1042 TripPhase::Dispatching
1043 | TripPhase::Ticketed
1044 | TripPhase::Notified
1045 | TripPhase::NotNotified
1046 | TripPhase::Withdrawn => Vec::new(),
1047 }
1048 }
1049}
1050
1051const ITALIAN: &[(&str, &str)] = &[
1053 (
1054 operations::OPEN,
1055 "Apre un nuovo viaggio, la pratica di una prenotazione, per il viaggiatore se è nominato.",
1056 ),
1057 (
1058 operations::SET_NAME,
1059 "Dà un nome al viaggio: come lo chiama il viaggiatore, in poche parole.",
1060 ),
1061 (
1062 operations::SET_TRAVEL_DATE,
1063 "Imposta il giorno in cui il viaggiatore preferisce volare.",
1064 ),
1065 (
1066 operations::ADD_EXTRA,
1067 "Aggiunge un extra al viaggio, cos'è, quanti e a che prezzo; ogni extra si aggiunge da solo.",
1068 ),
1069 (
1070 operations::ASSIGN_PAYER,
1071 "Dice chi paga un extra: il viaggiatore, l'azienda o la compagnia aerea.",
1072 ),
1073 (
1074 operations::CHANGE_EXTRA,
1075 "Cambia un extra già nel viaggio: descrizione, quantità o prezzo unitario.",
1076 ),
1077 (
1078 operations::SET_TRAVELER,
1079 "Sceglie il viaggiatore della pratica.",
1080 ),
1081 (
1082 operations::CHANGE_TRAVELER,
1083 "Mette un altro viaggiatore sul viaggio.",
1084 ),
1085 (
1086 operations::PROTECT_LEG,
1087 "Blocca una tratta della prenotazione perché niente la cambi, chiesto da solo; «non toccare il ritorno» detto mentre si cambia l'andata è una condizione del turno, non un blocco.",
1088 ),
1089 (
1090 operations::REQUEST_REBOOKING,
1091 "Mostra la scheda di cambio per il volo proposto dalla compagnia aerea su una tratta.",
1092 ),
1093 (operations::REBOOK, "Invia il cambio alla compagnia aerea."),
1094 (
1095 operations::WITHDRAW,
1096 "Ritira la pratica prima che un cambio sia inviato.",
1097 ),
1098 (
1099 operations::ACKNOWLEDGE_CARD,
1100 "Prende atto della scheda sullo schermo.",
1101 ),
1102];
1103
1104fn committed_receipt(committed: &CommittedEvent<TripEvent>) -> OperationalReceipt {
1107 let event_ids = vec![committed.event_id];
1108 let status_code = committed.payload.event_type().to_owned();
1109 let (severity, title, body) = receipt_copy(&committed.payload);
1110 OperationalReceipt {
1111 receipt_id: ReceiptId::derive(&event_ids, &status_code),
1112 event_ids,
1113 severity,
1114 title,
1115 body,
1116 status_code,
1117 artifact_refs: Vec::new(),
1118 }
1119}
1120
1121fn redacted_receipt(redacted: &RedactedEvent) -> OperationalReceipt {
1127 let event_ids = vec![redacted.event_id];
1128 let status_code = "trip.detail_erased".to_owned();
1129 OperationalReceipt {
1130 receipt_id: ReceiptId::derive(&event_ids, &status_code),
1131 event_ids,
1132 severity: ReceiptSeverity::Info,
1133 title: LocalizedText::new("Detail erased").with("it", "Dettaglio cancellato"),
1134 body: LocalizedText::new(
1135 "This change is still on record; the detail of what changed was erased.",
1136 )
1137 .with(
1138 "it",
1139 "Questa modifica resta a registro; il dettaglio di cosa è cambiato è stato cancellato.",
1140 ),
1141 status_code,
1142 artifact_refs: Vec::new(),
1143 }
1144}
1145
1146fn receipt_copy(event: &TripEvent) -> (ReceiptSeverity, LocalizedText, LocalizedText) {
1148 match event {
1149 TripEvent::Opened => (
1150 ReceiptSeverity::Success,
1151 LocalizedText::new("Case opened").with("it", "Pratica aperta"),
1152 LocalizedText::new("A new disruption case is open.")
1153 .with("it", "È aperta una nuova pratica."),
1154 ),
1155 TripEvent::NameSet { value } => (
1156 ReceiptSeverity::Success,
1157 LocalizedText::new("Trip named").with("it", "Nome impostato"),
1158 LocalizedText::new(format!("The trip is now called \"{value}\"."))
1159 .with("it", format!("Il viaggio ora si chiama \"{value}\".")),
1160 ),
1161 TripEvent::TravelDateSet { value } => (
1162 ReceiptSeverity::Success,
1163 LocalizedText::new("Travel date set").with("it", "Data impostata"),
1164 LocalizedText::new(format!("The traveler would rather fly on {value}.")).with(
1165 "it",
1166 format!("Il viaggiatore preferisce volare il {value}."),
1167 ),
1168 ),
1169 TripEvent::ExtraAdded {
1170 description,
1171 quantity,
1172 unit_price_cents,
1173 ..
1174 } => (
1175 ReceiptSeverity::Success,
1176 LocalizedText::new("Extra added").with("it", "Extra aggiunto"),
1177 LocalizedText::new(format!(
1178 "{quantity} x \"{description}\" at {} each.",
1179 money(*unit_price_cents, "en")
1180 ))
1181 .with(
1182 "it",
1183 format!(
1184 "{quantity} x \"{description}\" a {} l'uno.",
1185 money(*unit_price_cents, "it")
1186 ),
1187 ),
1188 ),
1189 TripEvent::PayerAssigned { payer, .. } => (
1190 ReceiptSeverity::Success,
1191 LocalizedText::new("Payer set").with("it", "Pagante impostato"),
1192 LocalizedText::new(format!("Paid by {}.", payer_name(*payer, "en")))
1193 .with("it", format!("Lo paga {}.", payer_name(*payer, "it"))),
1194 ),
1195 TripEvent::ExtraChanged {
1196 description,
1197 quantity,
1198 unit_price_cents,
1199 ..
1200 } => {
1201 let mut english = Vec::new();
1202 let mut italian = Vec::new();
1203 if let Some(description) = description {
1204 english.push(format!("described as \"{description}\""));
1205 italian.push(format!("descritto come \"{description}\""));
1206 }
1207 if let Some(quantity) = quantity {
1208 english.push(format!("quantity {quantity}"));
1209 italian.push(format!("quantità {quantity}"));
1210 }
1211 if let Some(cents) = unit_price_cents {
1212 english.push(format!("{} each", money(*cents, "en")));
1213 italian.push(format!("{} l'uno", money(*cents, "it")));
1214 }
1215 (
1216 ReceiptSeverity::Success,
1217 LocalizedText::new("Extra changed").with("it", "Extra modificato"),
1218 LocalizedText::new(format!("The extra is now {}.", english.join(", ")))
1219 .with("it", format!("L'extra ora è {}.", italian.join(", "))),
1220 )
1221 }
1222 TripEvent::TravelerChanged {
1223 previous_traveler_id: None,
1224 display_name,
1225 ..
1226 } => (
1227 ReceiptSeverity::Success,
1228 LocalizedText::new("Traveler set").with("it", "Viaggiatore impostato"),
1229 LocalizedText::new(format!("The trip is for {}.", sentence_end(display_name))).with(
1230 "it",
1231 format!("Il viaggio è per {}.", sentence_end(display_name)),
1232 ),
1233 ),
1234 TripEvent::TravelerChanged { display_name, .. } => (
1235 ReceiptSeverity::Success,
1236 LocalizedText::new("Traveler changed").with("it", "Viaggiatore cambiato"),
1237 LocalizedText::new(format!(
1238 "{} is now the traveler.",
1239 sentence_end(display_name)
1240 ))
1241 .with("it", format!("Ora viaggia {}.", sentence_end(display_name))),
1242 ),
1243 TripEvent::LegProtected { leg } => (
1244 ReceiptSeverity::Success,
1245 LocalizedText::new("Leg kept").with("it", "Tratta bloccata"),
1246 LocalizedText::new(format!("Leg {leg} stays as it is; nothing will change it.")).with(
1247 "it",
1248 format!("La tratta {leg} resta com'è; niente la cambierà."),
1249 ),
1250 ),
1251 TripEvent::OfferQuoted {
1252 leg,
1253 flight,
1254 fare_difference_cents,
1255 ..
1256 } => (
1257 ReceiptSeverity::Info,
1258 LocalizedText::new("Rebooking quoted").with("it", "Cambio proposto"),
1259 LocalizedText::new(format!(
1260 "The airline quotes {flight} for leg {leg}, {} more.",
1261 money(*fare_difference_cents, "en")
1262 ))
1263 .with(
1264 "it",
1265 format!(
1266 "La compagnia aerea propone il volo {flight} per la tratta {leg}, {} in più.",
1267 money(*fare_difference_cents, "it")
1268 ),
1269 ),
1270 ),
1271 TripEvent::RebookingRequested { .. } => (
1272 ReceiptSeverity::Info,
1273 LocalizedText::new("Ready to rebook").with("it", "Pronto per il cambio"),
1274 LocalizedText::new("Confirm the card below to send the rebooking to the airline.")
1275 .with(
1276 "it",
1277 "Conferma la scheda qui sotto per inviare il cambio alla compagnia aerea.",
1278 ),
1279 ),
1280 TripEvent::RebookingSent => (
1281 ReceiptSeverity::Success,
1282 LocalizedText::new("Rebooking sent").with("it", "Cambio inviato"),
1283 LocalizedText::new(
1284 "The rebooking was sent to the airline; it is not confirmed until they answer.",
1285 )
1286 .with(
1287 "it",
1288 "Il cambio è stato inviato alla compagnia aerea; non è confermato finché non \
1289 risponde.",
1290 ),
1291 ),
1292 TripEvent::Withdrawn => (
1293 ReceiptSeverity::Success,
1294 LocalizedText::new("Case withdrawn").with("it", "Pratica ritirata"),
1295 LocalizedText::new("The case was withdrawn before any rebooking was sent.").with(
1296 "it",
1297 "La pratica è stata ritirata prima di inviare un cambio.",
1298 ),
1299 ),
1300 TripEvent::AirlineOutcomeRecorded {
1301 status,
1302 ticket_number,
1303 reason_code,
1304 } => {
1305 let severity = match status {
1306 turnframe_core::event::ExternalStatus::Rejected
1307 | turnframe_core::event::ExternalStatus::NotDelivered => ReceiptSeverity::Warning,
1308 _ => ReceiptSeverity::Success,
1309 };
1310 let detail = ticket_number
1311 .as_deref()
1312 .or(reason_code.as_deref())
1313 .unwrap_or("-");
1314 (
1315 severity,
1316 LocalizedText::new("Airline answered").with("it", "Risposta della compagnia"),
1317 LocalizedText::new(format!("The airline reported {status:?} ({detail}).")).with(
1318 "it",
1319 format!("La compagnia aerea ha comunicato {status:?} ({detail})."),
1320 ),
1321 )
1322 }
1323 }
1324}
1325
1326impl PureWorkflow for TripWorkflow {
1327 fn apply(
1328 &self,
1329 state: Option<&TripState>,
1330 command: &TripCommand,
1331 ) -> Result<Applied<TripState, TripEvent>, DomainRejection> {
1332 apply(state, command)
1333 }
1334
1335 fn event_type(&self, event: &TripEvent) -> String {
1336 event.event_type().to_owned()
1337 }
1338}
1339
1340#[cfg(test)]
1341mod tests {
1342 use uuid::Uuid;
1343
1344 use super::*;
1345
1346 fn copy(event: &TripEvent, locale: &str) -> String {
1347 let (_, _, body) = receipt_copy(event);
1348 body.resolve(&Locale::new(locale)).to_owned()
1349 }
1350
1351 #[test]
1352 fn an_extra_change_that_changes_nothing_compiles_to_nothing() {
1353 let state = crate::workflows::trip::model::unassigned_case();
1354 let compiled = TripWorkflow::compile_operation(
1355 Some(&state),
1356 &OperationKey::from(operations::CHANGE_EXTRA),
1357 &serde_json::json!({ "extra": 1 }),
1358 );
1359 assert_eq!(compiled.unwrap(), Vec::new());
1360 }
1361
1362 #[test]
1363 fn an_extra_receipt_shows_its_price_as_money() {
1364 let event = TripEvent::ExtraAdded {
1365 extra_id: Uuid::nil(),
1366 description: "bags".to_owned(),
1367 quantity: 2,
1368 unit_price_cents: 4_050,
1369 };
1370 assert_eq!(copy(&event, "en-GB"), "2 x \"bags\" at €40.50 each.");
1371 assert_eq!(copy(&event, "it"), "2 x \"bags\" a 40,50 € l'uno.");
1372 }
1373
1374 #[test]
1375 fn a_payer_receipt_names_the_payer() {
1376 let event = TripEvent::PayerAssigned {
1377 extra_id: Uuid::nil(),
1378 payer: Payer::Airline,
1379 };
1380 assert_eq!(copy(&event, "en-GB"), "Paid by the airline.");
1381 }
1382
1383 #[test]
1384 fn the_rebooking_card_shows_the_quoted_fare() {
1385 let state = crate::workflows::trip::model::awaiting_rebooking_confirmation();
1386 let body = rebooking_card_body(&state);
1387 assert_eq!(
1388 body.resolve(&Locale::new("en-GB")),
1389 "Leg 1 (FCO→LIS) on AZ612, 2026-10-05 13:10, €84.00 more. Once sent, the airline \
1390 decides."
1391 );
1392 }
1393}