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