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, 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
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                        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                // Naming an extra and nothing to change on it changes nothing.
163                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            // Acknowledging the card on screen changes nothing, said by compiling
200            // nothing.
201            operations::ACKNOWLEDGE_CARD => return Ok(Vec::new()),
202            // The reserved code, not this workflow's own, so the state explorer can
203            // report a catalog that offers what the compiler does not know.
204            _ => 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
214/// The extra a user's number names, as the record lists it from 1.
215fn 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
239/// The argument naming a leg, by its number in the booking.
240fn 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
250/// The operations that edit a case. Offered while it is editable, including in
251/// `AwaitingRebookingConfirmation`, where an edit takes the pending card down
252/// instead of being silently refused.
253fn 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
416/// A name that ends a sentence, without a full stop of its own beside the sentence's.
417fn sentence_end(name: &str) -> &str {
418    name.trim_end_matches('.')
419}
420
421/// The traveler a record argument names, under the name it was shown by.
422fn 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        // A traveler registered in the same message has no name yet.
429        display_name: record
430            .label
431            .unwrap_or_else(|| "the new traveler".to_owned()),
432    }
433}
434
435/// Choosing the traveler of a case that has none, from the traveler records.
436fn 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
451/// An amount in euro cents as a reader writes it: `€84.00`, or `84,00 €` in Italian.
452fn 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
461/// A payer as a sentence names it.
462fn 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
474/// Body copy of the rebooking card.
475fn 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
504/// The blocking rebooking confirmation of the `AwaitingRebookingConfirmation` phase.
505fn 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            // Still a refusal, nothing pending runs, and the case is told it was
527            // asked, so the phase's own instruction is not served again to someone
528            // who just answered it.
529            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    /// Each obligation as the question that closes it. An extra's payer is asked of
600    /// that extra: the answer gives only the payer.
601    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    /// The values a person may be told back, which here is what they gave and the
637    /// booking they gave it on; never the lifecycle or the ticket number.
638    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    /// Guidance that changes with the phase: while a case is being filled in, what
775    /// a kept leg means; once the rebooking card is up, to read the message as an
776    /// answer to it.
777    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    /// What the sample wants said while it acknowledges and asks.
793    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    /// And something else for the stage that answers.
812    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    /// The itinerary a quoted rebooking has, declared from the projection so it is
827    /// there on a turn where the user asks instead of clicking.
828    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    /// What the destructive confirmation is about, in words that name the thing.
845    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    /// The payers this workflow accepts, and no others.
867    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    /// A complete trip can take another extra, and the rebooking of the quoted leg once a
882    /// quote is in; one still owing something offers nothing, because what it owes is asked
883    /// first. The runtime offers a step only when its dry run on the record passes.
884    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    /// Why nothing changed: the name already there.
924    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            // Choosing the first traveler replaces nobody: it needs no review.
947            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            // The card names what it was drawn from: a new quote changes both.
1030            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    /// The operations a view offers, with their summaries in English.
1043    /// Withdrawing takes only a record already in view: the sample's coverage of
1044    /// `RequiresCatalogedCase`.
1045    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    /// What a trip may be asked to do in some phase, once it exists.
1062    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                // The first traveler is chosen from the traveler records; changing it
1092                // later is the reviewed change.
1093                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                // Only the card's own button runs this: a user asking to see the
1104                // confirmation again gets the recap, not a bare "Confirm?".
1105                offered.push(
1106                    operation(
1107                        operations::REBOOK,
1108                        "Send the rebooking to the airline.",
1109                        TargetPolicy::RequiresCatalogedCase,
1110                    )
1111                    .card_only(),
1112                );
1113                // The card's «Keep my flight» records that it was asked; only its
1114                // button runs it.
1115                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
1135/// What each operation does, in Italian.
1136const 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
1191/// Renders one committed event as a receipt, copying the real event id so the
1192/// claim is backed by the ledger.
1193fn 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
1208/// Renders an event whose payload was erased.
1209///
1210/// The receipt still cites the event, so the claim guard still backs it; what the
1211/// copy must not do is describe a change whose values are gone. The event type is
1212/// kept out of the copy too: a user is owed a sentence, not a symbol.
1213fn 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
1233/// Server-authored copy, in English with an Italian translation.
1234fn 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}