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> {
842 if view.phase != TripPhase::Collecting || !view.obligations.is_empty() {
843 return Vec::new();
844 }
845 vec![
846 LocalizedText::new("Add another extra.").with("it", "Aggiungere un altro extra."),
847 LocalizedText::new("Rebook the quoted flight: a card asks to confirm first.").with(
848 "it",
849 "Cambiare il volo proposto: prima una scheda chiede conferma.",
850 ),
851 ]
852 }
853
854 fn compile_act(
855 &self,
856 state: Option<&TripState>,
857 _view: &ViewOf<Self>,
858 act: &ResolvedAct,
859 ) -> Result<Vec<TripCommand>, DomainRejection> {
860 match &act.kind {
861 ResolvedActKind::StartWorkflow => Ok(vec![TripCommand::Open]),
862 ResolvedActKind::ApplyOperation { operation } => {
863 Self::compile_operation(state, operation, &act.arguments)
864 }
865 _ => Err(reject(rejection::UNSUPPORTED_ACT)),
866 }
867 }
868
869 fn nothing_changed(
871 &self,
872 state: Option<&TripState>,
873 act: &ResolvedAct,
874 ) -> Option<LocalizedText> {
875 let ResolvedActKind::ApplyOperation { operation } = &act.kind else {
876 return None;
877 };
878 if operation.as_str() != operations::SET_NAME {
879 return None;
880 }
881 let name = state?.name.as_deref()?;
882 Some(
883 LocalizedText::new(format!(
884 "The trip is already called \"{name}\". Say which name you want instead."
885 ))
886 .with(
887 Locale::from("it-IT"),
888 format!("Il viaggio si chiama già «{name}». Dimmi quale nome vuoi."),
889 ),
890 )
891 }
892
893 fn command_policy(&self, state: Option<&TripState>, command: &TripCommand) -> CommandPolicy {
894 match command {
895 TripCommand::ChangeTraveler { .. }
897 if !self.with_cards || state.is_some_and(|state| state.traveler.is_none()) =>
898 {
899 CommandPolicy::low_risk()
900 }
901 TripCommand::Withdraw if !self.with_cards => CommandPolicy::low_risk(),
902 TripCommand::Rebook => CommandPolicy {
903 risk: RiskClass::ExternalRegulated,
904 confirmation: ConfirmationPolicy::ExplicitClick,
905 atomicity: AtomicityScope::ExternalSaga {
906 saga: "trip.rebooking".to_owned(),
907 },
908 claim_mode: ClaimMode::ServerReceiptOnly,
909 },
910 TripCommand::Withdraw => CommandPolicy {
911 risk: RiskClass::Destructive,
912 confirmation: ConfirmationPolicy::ExplicitClick,
913 atomicity: AtomicityScope::PerCase,
914 claim_mode: ClaimMode::ServerReceiptOnly,
915 },
916 TripCommand::ChangeTraveler { .. } => CommandPolicy {
917 risk: RiskClass::SensitiveDataChange,
918 confirmation: ConfirmationPolicy::ReviewCard,
919 atomicity: AtomicityScope::PerCase,
920 claim_mode: ClaimMode::EventReferencedParaphrase,
921 },
922 TripCommand::RecordAirlineOutcome { .. } => CommandPolicy {
923 risk: RiskClass::ExternalRegulated,
924 confirmation: ConfirmationPolicy::None,
925 atomicity: AtomicityScope::PerCase,
926 claim_mode: ClaimMode::ServerReceiptOnly,
927 },
928 TripCommand::Open
929 | TripCommand::OpenFor { .. }
930 | TripCommand::SetName { .. }
931 | TripCommand::SetTravelDate { .. }
932 | TripCommand::AddExtra { .. }
933 | TripCommand::AssignPayer { .. }
934 | TripCommand::ChangeExtra { .. }
935 | TripCommand::ProtectLeg { .. }
936 | TripCommand::Requote { .. }
937 | TripCommand::RequestRebooking { .. } => CommandPolicy::low_risk(),
938 }
939 }
940
941 fn validate_command(
942 &self,
943 state: Option<&TripState>,
944 command: &TripCommand,
945 ) -> Result<(), DomainRejection> {
946 validate(state, command)
947 }
948
949 fn receipts(
950 &self,
951 events: &[ReceiptEvent<TripEvent>],
952 locale: &Locale,
953 ) -> Vec<OperationalReceipt> {
954 let _ = locale;
955 events
956 .iter()
957 .map(|event| match event {
958 ReceiptEvent::Committed(committed) => committed_receipt(committed),
959 ReceiptEvent::Redacted(redacted) => redacted_receipt(redacted),
960 })
961 .collect()
962 }
963
964 fn build_interaction(
965 &self,
966 state: Option<&TripState>,
967 view: &ViewOf<Self>,
968 requirement: &InteractionRequirement,
969 ) -> Result<InteractionSpec, DomainRejection> {
970 let mut spec = requirement.to_spec(view.case_ref.clone());
971 if let Some(state) = state {
972 let preview =
974 canonical_digest(state).map_err(|_| reject(rejection::INVALID_ARGUMENTS))?;
975 spec.payload = spec.payload.with_metadata(serde_json::json!({
976 "preview_hash": preview.as_str(),
977 "bound_revision": view.case_ref.expected_revision.value(),
978 }));
979 }
980 Ok(spec)
981 }
982}
983
984impl TripWorkflow {
985 fn offered(view: &ViewOf<Self>) -> Vec<OperationSpec> {
987 let withdraw = operation(
990 operations::WITHDRAW,
991 "Withdraw the case before a rebooking is sent.",
992 TargetPolicy::RequiresCatalogedCase,
993 );
994 let request_rebooking = leg_argument(operation(
995 operations::REQUEST_REBOOKING,
996 "Show the rebooking card for the flight the airline quoted for a leg.",
997 TargetPolicy::RequiresExistingCase,
998 ));
999 match view.phase {
1000 TripPhase::PreDraft => vec![
1001 operation(
1002 operations::OPEN,
1003 "Open a new trip, the disruption case of one booking, for its traveler when named.",
1004 TargetPolicy::NewCaseOnly,
1005 )
1006 .arguments::<OpenArgs>()
1007 .argument("traveler", |a| {
1008 a.label("traveler")
1009 .label_in("it-IT", "viaggiatore")
1010 .record("traveler")
1011 }),
1012 ],
1013 TripPhase::Collecting | TripPhase::Refused => {
1014 let mut offered = editing_operations();
1015 if view.obligations.contains(&TripObligation::SelectTraveler) {
1018 offered.retain(|spec| spec.key.as_str() != operations::CHANGE_TRAVELER);
1019 offered.push(set_traveler());
1020 }
1021 offered.push(request_rebooking);
1022 offered.push(withdraw);
1023 offered
1024 }
1025 TripPhase::AwaitingRebookingConfirmation => {
1026 let mut offered = editing_operations();
1027 offered.push(
1030 operation(
1031 operations::REBOOK,
1032 "Send the rebooking to the airline.",
1033 TargetPolicy::RequiresCatalogedCase,
1034 )
1035 .card_only(),
1036 );
1037 offered.push(
1040 operation(
1041 operations::ACKNOWLEDGE_CARD,
1042 "Acknowledge the card currently on screen.",
1043 TargetPolicy::RequiresCatalogedCase,
1044 )
1045 .card_only(),
1046 );
1047 offered.push(withdraw);
1048 offered
1049 }
1050 TripPhase::Dispatching
1051 | TripPhase::Ticketed
1052 | TripPhase::Notified
1053 | TripPhase::NotNotified
1054 | TripPhase::Withdrawn => Vec::new(),
1055 }
1056 }
1057}
1058
1059const ITALIAN: &[(&str, &str)] = &[
1061 (
1062 operations::OPEN,
1063 "Apre un nuovo viaggio, la pratica di una prenotazione, per il viaggiatore se è nominato.",
1064 ),
1065 (
1066 operations::SET_NAME,
1067 "Dà un nome al viaggio: come lo chiama il viaggiatore, in poche parole.",
1068 ),
1069 (
1070 operations::SET_TRAVEL_DATE,
1071 "Imposta il giorno in cui il viaggiatore preferisce volare.",
1072 ),
1073 (
1074 operations::ADD_EXTRA,
1075 "Aggiunge un extra al viaggio, cos'è, quanti e a che prezzo; ogni extra si aggiunge da solo.",
1076 ),
1077 (
1078 operations::ASSIGN_PAYER,
1079 "Dice chi paga un extra: il viaggiatore, l'azienda o la compagnia aerea.",
1080 ),
1081 (
1082 operations::CHANGE_EXTRA,
1083 "Cambia un extra già nel viaggio: descrizione, quantità o prezzo unitario.",
1084 ),
1085 (
1086 operations::SET_TRAVELER,
1087 "Sceglie il viaggiatore della pratica.",
1088 ),
1089 (
1090 operations::CHANGE_TRAVELER,
1091 "Mette un altro viaggiatore sul viaggio.",
1092 ),
1093 (
1094 operations::PROTECT_LEG,
1095 "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.",
1096 ),
1097 (
1098 operations::REQUEST_REBOOKING,
1099 "Mostra la scheda di cambio per il volo proposto dalla compagnia aerea su una tratta.",
1100 ),
1101 (operations::REBOOK, "Invia il cambio alla compagnia aerea."),
1102 (
1103 operations::WITHDRAW,
1104 "Ritira la pratica prima che un cambio sia inviato.",
1105 ),
1106 (
1107 operations::ACKNOWLEDGE_CARD,
1108 "Prende atto della scheda sullo schermo.",
1109 ),
1110];
1111
1112fn committed_receipt(committed: &CommittedEvent<TripEvent>) -> OperationalReceipt {
1115 let event_ids = vec![committed.event_id];
1116 let status_code = committed.payload.event_type().to_owned();
1117 let (severity, title, body) = receipt_copy(&committed.payload);
1118 OperationalReceipt {
1119 receipt_id: ReceiptId::derive(&event_ids, &status_code),
1120 event_ids,
1121 severity,
1122 title,
1123 body,
1124 status_code,
1125 artifact_refs: Vec::new(),
1126 }
1127}
1128
1129fn redacted_receipt(redacted: &RedactedEvent) -> OperationalReceipt {
1135 let event_ids = vec![redacted.event_id];
1136 let status_code = "trip.detail_erased".to_owned();
1137 OperationalReceipt {
1138 receipt_id: ReceiptId::derive(&event_ids, &status_code),
1139 event_ids,
1140 severity: ReceiptSeverity::Info,
1141 title: LocalizedText::new("Detail erased").with("it", "Dettaglio cancellato"),
1142 body: LocalizedText::new(
1143 "This change is still on record; the detail of what changed was erased.",
1144 )
1145 .with(
1146 "it",
1147 "Questa modifica resta a registro; il dettaglio di cosa è cambiato è stato cancellato.",
1148 ),
1149 status_code,
1150 artifact_refs: Vec::new(),
1151 }
1152}
1153
1154fn receipt_copy(event: &TripEvent) -> (ReceiptSeverity, LocalizedText, LocalizedText) {
1156 match event {
1157 TripEvent::Opened => (
1158 ReceiptSeverity::Success,
1159 LocalizedText::new("Case opened").with("it", "Pratica aperta"),
1160 LocalizedText::new("A new disruption case is open.")
1161 .with("it", "È aperta una nuova pratica."),
1162 ),
1163 TripEvent::NameSet { value } => (
1164 ReceiptSeverity::Success,
1165 LocalizedText::new("Trip named").with("it", "Nome impostato"),
1166 LocalizedText::new(format!("The trip is now called \"{value}\"."))
1167 .with("it", format!("Il viaggio ora si chiama \"{value}\".")),
1168 ),
1169 TripEvent::TravelDateSet { value } => (
1170 ReceiptSeverity::Success,
1171 LocalizedText::new("Travel date set").with("it", "Data impostata"),
1172 LocalizedText::new(format!("The traveler would rather fly on {value}.")).with(
1173 "it",
1174 format!("Il viaggiatore preferisce volare il {value}."),
1175 ),
1176 ),
1177 TripEvent::ExtraAdded {
1178 description,
1179 quantity,
1180 unit_price_cents,
1181 ..
1182 } => (
1183 ReceiptSeverity::Success,
1184 LocalizedText::new("Extra added").with("it", "Extra aggiunto"),
1185 LocalizedText::new(format!(
1186 "{quantity} x \"{description}\" at {} each.",
1187 money(*unit_price_cents, "en")
1188 ))
1189 .with(
1190 "it",
1191 format!(
1192 "{quantity} x \"{description}\" a {} l'uno.",
1193 money(*unit_price_cents, "it")
1194 ),
1195 ),
1196 ),
1197 TripEvent::PayerAssigned { payer, .. } => (
1198 ReceiptSeverity::Success,
1199 LocalizedText::new("Payer set").with("it", "Pagante impostato"),
1200 LocalizedText::new(format!("Paid by {}.", payer_name(*payer, "en")))
1201 .with("it", format!("Lo paga {}.", payer_name(*payer, "it"))),
1202 ),
1203 TripEvent::ExtraChanged {
1204 description,
1205 quantity,
1206 unit_price_cents,
1207 ..
1208 } => {
1209 let mut english = Vec::new();
1210 let mut italian = Vec::new();
1211 if let Some(description) = description {
1212 english.push(format!("described as \"{description}\""));
1213 italian.push(format!("descritto come \"{description}\""));
1214 }
1215 if let Some(quantity) = quantity {
1216 english.push(format!("quantity {quantity}"));
1217 italian.push(format!("quantità {quantity}"));
1218 }
1219 if let Some(cents) = unit_price_cents {
1220 english.push(format!("{} each", money(*cents, "en")));
1221 italian.push(format!("{} l'uno", money(*cents, "it")));
1222 }
1223 (
1224 ReceiptSeverity::Success,
1225 LocalizedText::new("Extra changed").with("it", "Extra modificato"),
1226 LocalizedText::new(format!("The extra is now {}.", english.join(", ")))
1227 .with("it", format!("L'extra ora è {}.", italian.join(", "))),
1228 )
1229 }
1230 TripEvent::TravelerChanged {
1231 previous_traveler_id: None,
1232 display_name,
1233 ..
1234 } => (
1235 ReceiptSeverity::Success,
1236 LocalizedText::new("Traveler set").with("it", "Viaggiatore impostato"),
1237 LocalizedText::new(format!("The trip is for {}.", sentence_end(display_name))).with(
1238 "it",
1239 format!("Il viaggio è per {}.", sentence_end(display_name)),
1240 ),
1241 ),
1242 TripEvent::TravelerChanged { display_name, .. } => (
1243 ReceiptSeverity::Success,
1244 LocalizedText::new("Traveler changed").with("it", "Viaggiatore cambiato"),
1245 LocalizedText::new(format!(
1246 "{} is now the traveler.",
1247 sentence_end(display_name)
1248 ))
1249 .with("it", format!("Ora viaggia {}.", sentence_end(display_name))),
1250 ),
1251 TripEvent::LegProtected { leg } => (
1252 ReceiptSeverity::Success,
1253 LocalizedText::new("Leg kept").with("it", "Tratta bloccata"),
1254 LocalizedText::new(format!("Leg {leg} stays as it is; nothing will change it.")).with(
1255 "it",
1256 format!("La tratta {leg} resta com'è; niente la cambierà."),
1257 ),
1258 ),
1259 TripEvent::OfferQuoted {
1260 leg,
1261 flight,
1262 fare_difference_cents,
1263 ..
1264 } => (
1265 ReceiptSeverity::Info,
1266 LocalizedText::new("Rebooking quoted").with("it", "Cambio proposto"),
1267 LocalizedText::new(format!(
1268 "The airline quotes {flight} for leg {leg}, {} more.",
1269 money(*fare_difference_cents, "en")
1270 ))
1271 .with(
1272 "it",
1273 format!(
1274 "La compagnia aerea propone il volo {flight} per la tratta {leg}, {} in più.",
1275 money(*fare_difference_cents, "it")
1276 ),
1277 ),
1278 ),
1279 TripEvent::RebookingRequested { .. } => (
1280 ReceiptSeverity::Info,
1281 LocalizedText::new("Ready to rebook").with("it", "Pronto per il cambio"),
1282 LocalizedText::new("Confirm the card below to send the rebooking to the airline.")
1283 .with(
1284 "it",
1285 "Conferma la scheda qui sotto per inviare il cambio alla compagnia aerea.",
1286 ),
1287 ),
1288 TripEvent::RebookingSent => (
1289 ReceiptSeverity::Success,
1290 LocalizedText::new("Rebooking sent").with("it", "Cambio inviato"),
1291 LocalizedText::new(
1292 "The rebooking was sent to the airline; it is not confirmed until they answer.",
1293 )
1294 .with(
1295 "it",
1296 "Il cambio è stato inviato alla compagnia aerea; non è confermato finché non \
1297 risponde.",
1298 ),
1299 ),
1300 TripEvent::Withdrawn => (
1301 ReceiptSeverity::Success,
1302 LocalizedText::new("Case withdrawn").with("it", "Pratica ritirata"),
1303 LocalizedText::new("The case was withdrawn before any rebooking was sent.").with(
1304 "it",
1305 "La pratica è stata ritirata prima di inviare un cambio.",
1306 ),
1307 ),
1308 TripEvent::AirlineOutcomeRecorded {
1309 status,
1310 ticket_number,
1311 reason_code,
1312 } => {
1313 let severity = match status {
1314 turnframe_core::event::ExternalStatus::Rejected
1315 | turnframe_core::event::ExternalStatus::NotDelivered => ReceiptSeverity::Warning,
1316 _ => ReceiptSeverity::Success,
1317 };
1318 let detail = ticket_number
1319 .as_deref()
1320 .or(reason_code.as_deref())
1321 .unwrap_or("-");
1322 (
1323 severity,
1324 LocalizedText::new("Airline answered").with("it", "Risposta della compagnia"),
1325 LocalizedText::new(format!("The airline reported {status:?} ({detail}).")).with(
1326 "it",
1327 format!("La compagnia aerea ha comunicato {status:?} ({detail})."),
1328 ),
1329 )
1330 }
1331 }
1332}
1333
1334impl PureWorkflow for TripWorkflow {
1335 fn apply(
1336 &self,
1337 state: Option<&TripState>,
1338 command: &TripCommand,
1339 ) -> Result<Applied<TripState, TripEvent>, DomainRejection> {
1340 apply(state, command)
1341 }
1342
1343 fn event_type(&self, event: &TripEvent) -> String {
1344 event.event_type().to_owned()
1345 }
1346}
1347
1348#[cfg(test)]
1349mod tests {
1350 use uuid::Uuid;
1351
1352 use super::*;
1353
1354 fn copy(event: &TripEvent, locale: &str) -> String {
1355 let (_, _, body) = receipt_copy(event);
1356 body.resolve(&Locale::new(locale)).to_owned()
1357 }
1358
1359 #[test]
1360 fn an_extra_change_that_changes_nothing_compiles_to_nothing() {
1361 let state = crate::workflows::trip::model::unassigned_case();
1362 let compiled = TripWorkflow::compile_operation(
1363 Some(&state),
1364 &OperationKey::from(operations::CHANGE_EXTRA),
1365 &serde_json::json!({ "extra": 1 }),
1366 );
1367 assert_eq!(compiled.unwrap(), Vec::new());
1368 }
1369
1370 #[test]
1371 fn an_extra_receipt_shows_its_price_as_money() {
1372 let event = TripEvent::ExtraAdded {
1373 extra_id: Uuid::nil(),
1374 description: "bags".to_owned(),
1375 quantity: 2,
1376 unit_price_cents: 4_050,
1377 };
1378 assert_eq!(copy(&event, "en-GB"), "2 x \"bags\" at €40.50 each.");
1379 assert_eq!(copy(&event, "it"), "2 x \"bags\" a 40,50 € l'uno.");
1380 }
1381
1382 #[test]
1383 fn a_payer_receipt_names_the_payer() {
1384 let event = TripEvent::PayerAssigned {
1385 extra_id: Uuid::nil(),
1386 payer: Payer::Airline,
1387 };
1388 assert_eq!(copy(&event, "en-GB"), "Paid by the airline.");
1389 }
1390
1391 #[test]
1392 fn the_rebooking_card_shows_the_quoted_fare() {
1393 let state = crate::workflows::trip::model::awaiting_rebooking_confirmation();
1394 let body = rebooking_card_body(&state);
1395 assert_eq!(
1396 body.resolve(&Locale::new("en-GB")),
1397 "Leg 1 (FCO→LIS) on AZ612, 2026-10-05 13:10, €84.00 more. Once sent, the airline \
1398 decides."
1399 );
1400 }
1401}