Skip to main content

turnframe_test/workflows/trip/
definition.rs

1//! The trip [`WorkflowDefinition`]: projection, catalog, compilation, policy,
2//! validation and receipts.
3//!
4//! [`PhaseOwnership::User`] means "the user must answer a blocking card", not "it
5//! is the user's move". Free-form collection is therefore [`PhaseOwnership::System`];
6//! only `AwaitingRebookingConfirmation` is user-owned, because only there does a
7//! persistent card own what a bare "yes" means.
8
9use 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
45/// Key of the blocking rebooking confirmation requirement.
46pub const REBOOKING_CONFIRMATION_KEY: &str = "trip.rebooking_confirmation";
47
48/// Option that confirms the rebooking.
49pub const REBOOK_CONFIRM_OPTION: &str = "confirm";
50
51/// Option that keeps the booked flight.
52pub const REBOOK_DECLINE_OPTION: &str = "decline";
53
54/// The trip workflow: the disruption case of one booking.
55#[derive(Debug, Clone, Copy, Default)]
56#[non_exhaustive]
57pub struct TripWorkflow {
58    with_cards: bool,
59}
60
61impl TripWorkflow {
62    /// Builds the workflow: a rebooking is the only step a card confirms.
63    #[must_use]
64    pub const fn new() -> Self {
65        Self { with_cards: false }
66    }
67
68    /// The same workflow with a click for withdrawing and a review card for
69    /// changing the traveler, for exercising the card machinery; the sample itself
70    /// asks for neither.
71    #[must_use]
72    pub const fn with_cards(mut self) -> Self {
73        self.with_cards = true;
74        self
75    }
76
77    /// The phase a stored status projects to.
78    #[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                // A domain rule refusing with copy of its own, so the kit exercises
114                // the path a refusal takes to the user.
115                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                // Already the name on record: a valid act that changes nothing,
124                // said by compiling nothing.
125                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                // Naming an extra and nothing to change on it changes nothing.
162                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            // Acknowledging the card on screen changes nothing, said by compiling
199            // nothing.
200            operations::ACKNOWLEDGE_CARD => return Ok(Vec::new()),
201            // The reserved code, not this workflow's own, so the state explorer can
202            // report a catalog that offers what the compiler does not know.
203            _ => 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
213/// The extra a user's number names, as the record lists it from 1.
214fn 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
238/// The argument naming a leg, by its number in the booking.
239fn 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
249/// The operations that edit a case. Offered while it is editable, including in
250/// `AwaitingRebookingConfirmation`, where an edit takes the pending card down
251/// instead of being silently refused.
252fn 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
400/// A name that ends a sentence, without a full stop of its own beside the sentence's.
401fn sentence_end(name: &str) -> &str {
402    name.trim_end_matches('.')
403}
404
405/// The traveler a record argument names, under the name it was shown by.
406fn 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        // A traveler registered in the same message has no name yet.
413        display_name: record
414            .label
415            .unwrap_or_else(|| "the new traveler".to_owned()),
416    }
417}
418
419/// Choosing the traveler of a case that has none, from the traveler records.
420fn 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
435/// An amount in euro cents as a reader writes it: `€84.00`, or `84,00 €` in Italian.
436fn 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
445/// A payer as a sentence names it.
446fn 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
458/// Body copy of the rebooking card.
459fn 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
488/// The blocking rebooking confirmation of the `AwaitingRebookingConfirmation` phase.
489fn 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            // Still a refusal, nothing pending runs, and the case is told it was
511            // asked, so the phase's own instruction is not served again to someone
512            // who just answered it.
513            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    /// Each obligation as the question that closes it. An extra's payer is asked of
584    /// that extra: the answer gives only the payer.
585    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    /// The values a person may be told back, which here is what they gave and the
621    /// booking they gave it on; never the lifecycle or the ticket number.
622    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    /// Guidance that changes with the phase: while a case is being filled in, what
738    /// a kept leg means; once the rebooking card is up, to read the message as an
739    /// answer to it.
740    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    /// What the sample wants said while it acknowledges and asks.
756    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    /// And something else for the stage that answers.
775    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    /// The itinerary a quoted rebooking has, declared from the projection so it is
785    /// there on a turn where the user asks instead of clicking.
786    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    /// What the destructive confirmation is about, in words that name the thing.
803    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    /// The payers this workflow accepts, and no others.
825    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    /// A complete trip can take another extra or be rebooked when a quote is in;
840    /// one still owing something offers nothing, because what it owes is asked first.
841    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    /// Why nothing changed: the name already there, and what to say next.
870    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            // Choosing the first traveler replaces nobody: it needs no review.
896            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            // The card names what it was drawn from: a new quote changes both.
973            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    /// The operations a view offers, with their summaries in English.
986    fn offered(view: &ViewOf<Self>) -> Vec<OperationSpec> {
987        // Withdrawing takes only a record already in view: the sample's coverage of
988        // `RequiresCatalogedCase`.
989        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                // The first traveler is chosen from the traveler records; changing it
1016                // later is the reviewed change.
1017                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                // Only the card's own button runs this: a user asking to see the
1028                // confirmation again gets the recap, not a bare "Confirm?".
1029                offered.push(
1030                    operation(
1031                        operations::REBOOK,
1032                        "Send the rebooking to the airline.",
1033                        TargetPolicy::RequiresCatalogedCase,
1034                    )
1035                    .card_only(),
1036                );
1037                // The card's «Keep my flight» records that it was asked; only its
1038                // button runs it.
1039                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
1059/// What each operation does, in Italian.
1060const 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
1112/// Renders one committed event as a receipt, copying the real event id so the
1113/// claim is backed by the ledger.
1114fn 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
1129/// Renders an event whose payload was erased.
1130///
1131/// The receipt still cites the event, so the claim guard still backs it; what the
1132/// copy must not do is describe a change whose values are gone. The event type is
1133/// kept out of the copy too: a user is owed a sentence, not a symbol.
1134fn 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
1154/// Server-authored copy, in English with an Italian translation.
1155fn 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}