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