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