Skip to main content

turnframe_test/workflows/trip/
definition.rs

1//! The trip [`WorkflowDefinition`]: projection, catalog, compilation, policy,
2//! validation and receipts.
3//!
4//! [`PhaseOwnership::User`] means "the user must answer a blocking card", not "it
5//! is the user's move". Free-form collection is therefore [`PhaseOwnership::System`];
6//! only `AwaitingRebookingConfirmation` is user-owned, because only there does a
7//! persistent card own what a bare "yes" means.
8
9use serde::de::DeserializeOwned;
10use turnframe_core::case::CaseRef;
11use turnframe_core::command::{
12    AtomicityScope, ClaimMode, CommandPolicy, ConfirmationPolicy, RiskClass,
13};
14use turnframe_core::error::DomainRejection;
15use turnframe_core::event::{
16    ArtifactRef, CommittedEvent, OperationalReceipt, ReceiptEvent, ReceiptSeverity, RedactedEvent,
17};
18use turnframe_core::flow::{
19    ConfirmationSubject, DomainEnumeration, EnumeratedValue, InteractionRequirement,
20    PhaseOwnership, StateField, ViewOf, WorkflowDefinition, WorkflowNotice, WorkflowView,
21};
22use turnframe_core::hash::canonical_digest;
23use turnframe_core::ids::{OperationKey, ReceiptId, WorkflowKey, WorkflowVersion};
24use turnframe_core::interaction::{
25    InteractionKind, InteractionOption, InteractionPayload, InteractionSpec, OptionStyle,
26    StoredInteractionAction,
27};
28use turnframe_core::locale::{Locale, LocalizedText};
29use turnframe_core::operation::{DateDirection, GlossaryTerm, OperationSpec};
30use turnframe_core::plan::TargetPolicy;
31use turnframe_core::response::NoticeSeverity;
32use turnframe_core::target::{ResolvedAct, ResolvedActKind};
33
34use crate::workflows::trip::apply::{apply, reject, rejection, validate};
35use crate::workflows::trip::command::{
36    AddExtraArgs, AssignPayerArgs, ChangeExtraArgs, ChangeTravelerArgs, LegArgs, OpenArgs,
37    SetNameArgs, SetTravelDateArgs, SetTravelerArgs, TravelerRecord, TripCommand, TripEvent,
38    operations,
39};
40use crate::workflows::trip::state::{
41    NewExtra, Payer, TripObligation, TripOutcome, TripPhase, TripState, TripStatus, TripTraveler,
42};
43use crate::workflows::{Applied, PureWorkflow};
44
45/// Key of the blocking rebooking confirmation requirement.
46pub const REBOOKING_CONFIRMATION_KEY: &str = "trip.rebooking_confirmation";
47
48/// Option that confirms the rebooking.
49pub const REBOOK_CONFIRM_OPTION: &str = "confirm";
50
51/// Option that keeps the booked flight.
52pub const REBOOK_DECLINE_OPTION: &str = "decline";
53
54/// The trip workflow: the disruption case of one booking.
55#[derive(Debug, Clone, Copy, Default)]
56#[non_exhaustive]
57pub struct TripWorkflow {
58    with_cards: bool,
59}
60
61impl TripWorkflow {
62    /// Builds the workflow: a rebooking is the only step a card confirms.
63    #[must_use]
64    pub const fn new() -> Self {
65        Self { with_cards: false }
66    }
67
68    /// The same workflow with a click for withdrawing and a review card for
69    /// changing the traveler, for exercising the card machinery; the sample itself
70    /// asks for neither.
71    #[must_use]
72    pub const fn with_cards(mut self) -> Self {
73        self.with_cards = true;
74        self
75    }
76
77    /// The phase a stored status projects to.
78    #[must_use]
79    pub fn phase_of(state: &TripState) -> TripPhase {
80        match state.status {
81            TripStatus::Draft => TripPhase::Collecting,
82            TripStatus::AwaitingRebookingConfirmation => TripPhase::AwaitingRebookingConfirmation,
83            TripStatus::Rebooking => TripPhase::Dispatching,
84            TripStatus::Ticketed => TripPhase::Ticketed,
85            TripStatus::Refused => TripPhase::Refused,
86            TripStatus::Notified => TripPhase::Notified,
87            TripStatus::NotNotified => TripPhase::NotNotified,
88            TripStatus::Withdrawn => TripPhase::Withdrawn,
89        }
90    }
91
92    fn compile_operation(
93        state: Option<&TripState>,
94        operation: &OperationKey,
95        arguments: &serde_json::Value,
96    ) -> Result<Vec<TripCommand>, DomainRejection> {
97        let command = match operation.as_str() {
98            operations::OPEN => {
99                let args: OpenArgs = if arguments.is_null() {
100                    OpenArgs::default()
101                } else {
102                    parse(arguments)?
103                };
104                match args.traveler {
105                    Some(record) => TripCommand::OpenFor {
106                        traveler: traveler_of(record),
107                    },
108                    None => TripCommand::Open,
109                }
110            }
111            operations::SET_NAME => {
112                let value = parse::<SetNameArgs>(arguments)?.value;
113                // A domain rule refusing with copy of its own, so the kit exercises
114                // the path a refusal takes to the user.
115                if value.trim().is_empty() {
116                    return Err(reject(rejection::EMPTY_NAME).with_explanation(
117                        LocalizedText::new("A trip name cannot be empty.").with(
118                            Locale::from("it-IT"),
119                            "Il nome del viaggio non può essere vuoto.",
120                        ),
121                    ));
122                }
123                // Already the name on record: a valid act that changes nothing,
124                // said by compiling nothing.
125                if state.is_some_and(|state| state.name.as_deref() == Some(value.as_str())) {
126                    return Ok(Vec::new());
127                }
128                TripCommand::SetName { value }
129            }
130            operations::SET_TRAVEL_DATE => TripCommand::SetTravelDate {
131                value: parse::<SetTravelDateArgs>(arguments)?.value,
132            },
133            operations::ADD_EXTRA => {
134                let args = parse::<AddExtraArgs>(arguments)?;
135                if args.unit_price.currency != "EUR" {
136                    return Err(reject(rejection::CURRENCY_NOT_ACCEPTED)
137                        .on_argument("/unit_price")
138                        .with_explanation(
139                            LocalizedText::new("Extras on this desk are in euros.")
140                                .with(Locale::from("it-IT"), "Gli extra qui sono in euro."),
141                        ));
142                }
143                TripCommand::AddExtra {
144                    extra: NewExtra {
145                        description: args.description,
146                        quantity: args.quantity,
147                        unit_price_cents: args.unit_price.minor,
148                    },
149                }
150            }
151            operations::ASSIGN_PAYER => {
152                let args = parse::<AssignPayerArgs>(arguments)?;
153                TripCommand::AssignPayer {
154                    extra_id: numbered_extra(state, args.extra)?,
155                    payer: args.payer,
156                }
157            }
158            operations::CHANGE_EXTRA => {
159                let args = parse::<ChangeExtraArgs>(arguments)?;
160                let extra_id = numbered_extra(state, args.extra)?;
161                // Naming an extra and nothing to change on it changes nothing.
162                if args.description.is_none()
163                    && args.quantity.is_none()
164                    && args.unit_price.is_none()
165                {
166                    return Ok(Vec::new());
167                }
168                TripCommand::ChangeExtra {
169                    extra_id,
170                    description: args.description,
171                    quantity: args.quantity,
172                    unit_price_cents: args.unit_price.map(|price| price.minor),
173                }
174            }
175            operations::CHANGE_TRAVELER => {
176                let traveler = parse::<ChangeTravelerArgs>(arguments)?.traveler;
177                TripCommand::ChangeTraveler {
178                    traveler: TripTraveler {
179                        traveler_id: turnframe_core::hash::derive_uuid(
180                            "turnframe.sample.trip.traveler",
181                            &[traveler.trim()],
182                        ),
183                        display_name: traveler.trim().to_owned(),
184                    },
185                }
186            }
187            operations::SET_TRAVELER => TripCommand::ChangeTraveler {
188                traveler: traveler_of(parse::<SetTravelerArgs>(arguments)?.traveler),
189            },
190            operations::PROTECT_LEG => TripCommand::ProtectLeg {
191                leg: parse::<LegArgs>(arguments)?.leg,
192            },
193            operations::REQUEST_REBOOKING => TripCommand::RequestRebooking {
194                leg: parse::<LegArgs>(arguments)?.leg,
195            },
196            operations::REBOOK => TripCommand::Rebook,
197            operations::WITHDRAW => TripCommand::Withdraw,
198            // Acknowledging the card on screen changes nothing, said by compiling
199            // nothing.
200            operations::ACKNOWLEDGE_CARD => return Ok(Vec::new()),
201            // The reserved code, not this workflow's own, so the state explorer can
202            // report a catalog that offers what the compiler does not know.
203            _ => return Err(reject(turnframe_core::error::UNKNOWN_OPERATION)),
204        };
205        Ok(vec![command])
206    }
207}
208
209fn parse<T: DeserializeOwned>(arguments: &serde_json::Value) -> Result<T, DomainRejection> {
210    serde_json::from_value(arguments.clone()).map_err(|_| reject(rejection::INVALID_ARGUMENTS))
211}
212
213/// The extra a user's number names, as the record lists it from 1.
214fn numbered_extra(state: Option<&TripState>, number: u32) -> Result<uuid::Uuid, DomainRejection> {
215    number
216        .checked_sub(1)
217        .and_then(|index| state?.extras.get(usize::try_from(index).ok()?))
218        .map(|extra| extra.extra_id)
219        .ok_or_else(|| {
220            reject(rejection::UNKNOWN_EXTRA)
221                .on_argument("/extra")
222                .with_explanation(
223                    LocalizedText::new("This trip has no extra with that number.").with(
224                        Locale::from("it-IT"),
225                        "Questo viaggio non ha un extra con quel numero.",
226                    ),
227                )
228        })
229}
230
231fn operation(key: &str, summary: &str, target: TargetPolicy) -> OperationSpec {
232    OperationSpec::new(key)
233        .summary(summary)
234        .target(target)
235        .mutating()
236}
237
238/// The argument naming a leg, by its number in the booking.
239fn leg_argument(spec: OperationSpec) -> OperationSpec {
240    spec.arguments::<LegArgs>().argument("leg", |a| {
241        a.label("leg number")
242            .label_in("it-IT", "numero della tratta")
243            .describe("The leg's number as the record lists it: the outbound is 1.")
244            .required()
245            .inferred()
246    })
247}
248
249/// The operations that edit a case. Offered while it is editable, including in
250/// `AwaitingRebookingConfirmation`, where an edit takes the pending card down
251/// instead of being silently refused.
252fn editing_operations() -> Vec<OperationSpec> {
253    let existing = TargetPolicy::RequiresExistingCase;
254    vec![
255        operation(
256            operations::SET_NAME,
257            "Name the trip: what the traveler calls it, in a few words.",
258            existing,
259        )
260        .arguments::<SetNameArgs>()
261        .argument("value", |a| {
262            a.label("trip name")
263                .label_in("it-IT", "nome del viaggio")
264                .required()
265        })
266        .example(
267            "the trip is for the Lisbon offsite",
268            serde_json::json!({ "value": "Lisbon offsite" }),
269        )
270        .example(
271            "chiamalo offsite Lisbona",
272            serde_json::json!({ "value": "offsite Lisbona" }),
273        )
274        .example_not_given("the trip name needs changing", ["value"])
275        .example_not_given("can I name the trip?", ["value"]),
276        operation(
277            operations::SET_TRAVEL_DATE,
278            "Set the day the traveler would rather fly.",
279            existing,
280        )
281        .arguments::<SetTravelDateArgs>()
282        .argument("value", |a| {
283            a.label("travel date")
284                .label_in("it-IT", "data del viaggio")
285                .required()
286                .date_direction(DateDirection::Future)
287        })
288        .example_not_given("I want to change the travel date", ["value"]),
289        operation(
290            operations::ADD_EXTRA,
291            "Add one extra to the trip, what it is, how many and at what price; each extra is added on its own.",
292            existing,
293        )
294        .arguments::<AddExtraArgs>()
295        .argument("description", |a| {
296            a.label("description")
297                .label_in("it-IT", "descrizione")
298                .required()
299        })
300        .argument("quantity", |a| {
301            a.label("quantity")
302                .label_in("it-IT", "quantità")
303                .describe("How many: the count this extra's own words give; «an extra» and «a bag» are one.")
304                .required()
305                .inferred()
306        })
307        .argument("unit_price", |a| {
308            a.label("unit price")
309                .label_in("it-IT", "prezzo unitario")
310                .required()
311                .money()
312        })
313        .example(
314            "40 euros for a checked bag",
315            serde_json::json!({
316                "description": "checked bag",
317                "quantity": 1,
318                "unit_price": { "minor": 4000, "currency": "EUR" }
319            }),
320        )
321        .example(
322            "2 airport meals at 15 euros each",
323            serde_json::json!({
324                "description": "airport meals",
325                "quantity": 2,
326                "unit_price": { "minor": 1500, "currency": "EUR" }
327            }),
328        ),
329        operation(
330            operations::ASSIGN_PAYER,
331            "Say who pays for one extra: the traveler, the company, or the airline.",
332            existing,
333        )
334        .arguments::<AssignPayerArgs>()
335        .argument("extra", |a| {
336            a.label("extra number")
337                .label_in("it-IT", "numero dell'extra")
338                .describe("The extra's number as the record lists it.")
339                .required()
340                .inferred()
341        })
342        .argument("payer", |a| {
343            a.label("who pays").label_in("it-IT", "chi paga").required()
344        }),
345        operation(
346            operations::CHANGE_EXTRA,
347            "Change an extra already on the trip: its description, quantity or unit price.",
348            existing,
349        )
350        .arguments::<ChangeExtraArgs>()
351        .argument("extra", |a| {
352            a.label("extra number")
353                .label_in("it-IT", "numero dell'extra")
354                .describe("The extra's number as the record lists it.")
355                .required()
356                .inferred()
357        })
358        .argument("description", |a| {
359            a.label("description").label_in("it-IT", "descrizione")
360        })
361        .argument("quantity", |a| {
362            a.label("quantity").label_in("it-IT", "quantità")
363        })
364        .argument("unit_price", |a| {
365            a.label("unit price")
366                .label_in("it-IT", "prezzo unitario")
367                .money()
368        })
369        .example(
370            "extra 2 is actually 3 bags",
371            serde_json::json!({ "extra": 2, "quantity": 3 }),
372        ),
373        operation(
374            operations::CHANGE_TRAVELER,
375            "Put another traveler on the trip.",
376            existing,
377        )
378        .arguments::<ChangeTravelerArgs>()
379        .argument("traveler", |a| {
380            a.label("traveler")
381                .label_in("it-IT", "viaggiatore")
382                .required()
383        })
384        .example(
385            "Tom Becker is flying instead",
386            serde_json::json!({ "traveler": "Tom Becker" }),
387        ),
388        leg_argument(operation(
389            operations::PROTECT_LEG,
390            "Lock one leg of the booking so nothing changes it, asked on its own; «don't touch the return» said while the outbound changes is a condition of the turn, not a lock.",
391            existing,
392        ))
393        .example(
394            "don't touch the return flight",
395            serde_json::json!({ "leg": 2 }),
396        ),
397    ]
398}
399
400/// A name that ends a sentence, without a full stop of its own beside the sentence's.
401fn sentence_end(name: &str) -> &str {
402    name.trim_end_matches('.')
403}
404
405/// The traveler a record argument names, under the name it was shown by.
406fn traveler_of(record: TravelerRecord) -> TripTraveler {
407    TripTraveler {
408        traveler_id: turnframe_core::hash::derive_uuid(
409            "turnframe.sample.trip.traveler_record",
410            &[record.case_id.as_str()],
411        ),
412        // A traveler registered in the same message has no name yet.
413        display_name: record
414            .label
415            .unwrap_or_else(|| "the new traveler".to_owned()),
416    }
417}
418
419/// Choosing the traveler of a case that has none, from the traveler records.
420fn set_traveler() -> OperationSpec {
421    operation(
422        operations::SET_TRAVELER,
423        "Choose the traveler the trip is for.",
424        TargetPolicy::RequiresExistingCase,
425    )
426    .arguments::<SetTravelerArgs>()
427    .argument("traveler", |a| {
428        a.label("traveler")
429            .label_in("it-IT", "viaggiatore")
430            .required()
431            .record("traveler")
432    })
433}
434
435/// An amount in euro cents as a reader writes it: `€84.00`, or `84,00 €` in Italian.
436fn money(cents: i64, language: &str) -> String {
437    let sign = if cents < 0 { "-" } else { "" };
438    let (whole, part) = (cents.unsigned_abs() / 100, cents.unsigned_abs() % 100);
439    match language {
440        "it" => format!("{sign}{whole},{part:02} €"),
441        _ => format!("{sign}€{whole}.{part:02}"),
442    }
443}
444
445/// A payer as a sentence names it.
446fn payer_name(payer: Payer, language: &str) -> &'static str {
447    let italian = language == "it";
448    match payer {
449        Payer::Traveler if italian => "il viaggiatore",
450        Payer::Traveler => "the traveler",
451        Payer::Company if italian => "l'azienda",
452        Payer::Company => "the company",
453        Payer::Airline if italian => "la compagnia aerea",
454        Payer::Airline => "the airline",
455    }
456}
457
458/// Body copy of the rebooking card.
459fn rebooking_card_body(state: &TripState) -> LocalizedText {
460    let Some(offer) = &state.offer else {
461        return LocalizedText::new("No rebooking is quoted.")
462            .with("it", "Nessun cambio è proposto.");
463    };
464    let route = state
465        .leg(offer.leg)
466        .map_or_else(String::new, |leg| format!(" ({}→{})", leg.from, leg.to));
467    let fare = offer.fare_difference_cents;
468    LocalizedText::new(format!(
469        "Leg {}{route} on {}, {}, {} more. Once sent, the airline decides.",
470        offer.leg,
471        offer.flight,
472        offer.departs,
473        money(fare, "en")
474    ))
475    .with(
476        "it",
477        format!(
478            "Tratta {}{route} sul volo {}, {}, {} in più. Una volta inviato, decide la \
479             compagnia aerea.",
480            offer.leg,
481            offer.flight,
482            offer.departs,
483            money(fare, "it")
484        ),
485    )
486}
487
488/// The blocking rebooking confirmation of the `AwaitingRebookingConfirmation` phase.
489fn rebooking_requirement(state: &TripState) -> InteractionRequirement {
490    let payload = InteractionPayload::new(
491        LocalizedText::new("Rebook this flight?").with("it", "Cambio questo volo?"),
492    )
493    .with_body(rebooking_card_body(state))
494    .with_option(
495        InteractionOption::new(
496            REBOOK_CONFIRM_OPTION,
497            LocalizedText::new("Confirm").with("it", "Conferma"),
498            StoredInteractionAction::ApplyOperation {
499                operation: OperationKey::from(operations::REBOOK),
500                arguments: serde_json::Value::Null,
501                freeform_argument: None,
502            },
503        )
504        .with_style(OptionStyle::Primary),
505    )
506    .with_option(
507        InteractionOption::new(
508            REBOOK_DECLINE_OPTION,
509            LocalizedText::new("Keep my flight").with("it", "Tengo il mio volo"),
510            // Still a refusal, nothing pending runs, and the case is told it was
511            // asked, so the phase's own instruction is not served again to someone
512            // who just answered it.
513            StoredInteractionAction::DeclineAndRecord {
514                operation: OperationKey::from(operations::ACKNOWLEDGE_CARD),
515            },
516        )
517        .with_style(OptionStyle::Danger),
518    );
519    InteractionRequirement::blocking(REBOOKING_CONFIRMATION_KEY, InteractionKind::ConfirmCommand)
520        .with_confirms_risk(RiskClass::ExternalRegulated)
521        .with_payload(payload)
522}
523
524fn airline_notice(state: &TripState) -> Option<WorkflowNotice> {
525    let status = state.external_status?;
526    Some(WorkflowNotice {
527        code: "trip.airline_status".to_owned(),
528        severity: NoticeSeverity::Info,
529        text: LocalizedText::new(format!("Airline status: {status:?}.")).with(
530            "it",
531            format!("Stato presso la compagnia aerea: {status:?}."),
532        ),
533    })
534}
535
536fn refusal_notice(state: &TripState) -> WorkflowNotice {
537    let code = state.refusal_code.as_deref().unwrap_or("unknown");
538    WorkflowNotice {
539        code: "trip.refused_by_airline".to_owned(),
540        severity: NoticeSeverity::Warning,
541        text: LocalizedText::new(format!(
542            "The airline refused the rebooking (code {code}). Another can be asked for."
543        ))
544        .with(
545            "it",
546            format!(
547                "La compagnia aerea ha rifiutato il cambio (codice {code}). Se ne può chiedere \
548                 un altro."
549            ),
550        ),
551    }
552}
553
554impl WorkflowDefinition for TripWorkflow {
555    type State = TripState;
556    type Phase = TripPhase;
557    type Obligation = TripObligation;
558    type Command = TripCommand;
559    type Event = TripEvent;
560    type Outcome = TripOutcome;
561
562    fn key(&self) -> WorkflowKey {
563        WorkflowKey::from("trip")
564    }
565
566    fn version(&self) -> WorkflowVersion {
567        WorkflowVersion::from("1")
568    }
569
570    fn phase_ownership(&self, phase: &TripPhase) -> PhaseOwnership {
571        match phase {
572            TripPhase::PreDraft | TripPhase::Collecting | TripPhase::Refused => {
573                PhaseOwnership::System
574            }
575            TripPhase::AwaitingRebookingConfirmation => PhaseOwnership::User,
576            TripPhase::Dispatching | TripPhase::Ticketed => PhaseOwnership::External,
577            TripPhase::Notified | TripPhase::NotNotified | TripPhase::Withdrawn => {
578                PhaseOwnership::Terminal
579            }
580        }
581    }
582
583    /// Each obligation as the question that closes it. An extra's payer is asked of
584    /// that extra: the answer gives only the payer.
585    fn obligation_act(
586        &self,
587        state: Option<&TripState>,
588        obligation: &TripObligation,
589    ) -> Option<turnframe_core::flow::ObligationAct> {
590        let TripObligation::AssignPayer { extra_id } = obligation else {
591            return None;
592        };
593        let position = state?
594            .extras
595            .iter()
596            .position(|extra| extra.extra_id == *extra_id)?;
597        Some(
598            turnframe_core::flow::ObligationAct::new(operations::ASSIGN_PAYER, ["payer"])
599                .given("extra", serde_json::json!(position + 1)),
600        )
601    }
602
603    fn obligation_sentence(&self, obligation: &TripObligation) -> Option<LocalizedText> {
604        let it = Locale::from("it-IT");
605        Some(match obligation {
606            TripObligation::SelectTraveler => {
607                LocalizedText::new("Who is travelling?").with(it, "Chi viaggia?")
608            }
609            TripObligation::AssignPayer { .. } => {
610                LocalizedText::new("An extra has no payer yet: who pays for it?")
611                    .with(it, "Un extra non ha ancora chi lo paga: chi lo paga?")
612            }
613            TripObligation::SetName => LocalizedText::new("What should I call this trip?")
614                .with(it, "Come chiamo questo viaggio?"),
615            TripObligation::SetTravelDate => LocalizedText::new("Which day would you rather fly?")
616                .with(it, "In che giorno preferisci volare?"),
617        })
618    }
619
620    /// The values a person may be told back, which here is what they gave and the
621    /// booking they gave it on; never the lifecycle or the ticket number.
622    fn narratable_state(&self, state: Option<&TripState>) -> Vec<StateField> {
623        let Some(state) = state else {
624            return Vec::new();
625        };
626        let mut held = Vec::new();
627        if let Some(traveler) = &state.traveler {
628            held.push(
629                StateField::new("traveler", serde_json::json!(traveler.display_name)).identifying(),
630            );
631        }
632        if let Some(name) = &state.name {
633            held.push(StateField::new("name", serde_json::json!(name)).identifying());
634        }
635        if let Some(date) = state.travel_date {
636            held.push(StateField::new(
637                "travel_date",
638                serde_json::json!(date.to_string()),
639            ));
640        }
641        if !state.legs.is_empty() {
642            let legs: Vec<String> = state
643                .legs
644                .iter()
645                .map(|leg| {
646                    let kept = if leg.protected { ", kept as it is" } else { "" };
647                    format!(
648                        "{}. {} {}→{} {}, {:?}{kept}",
649                        leg.number, leg.flight, leg.from, leg.to, leg.departs, leg.status
650                    )
651                })
652                .collect();
653            held.push(StateField::new("legs", serde_json::json!(legs)));
654        }
655        if !state.extras.is_empty() {
656            let extras: Vec<String> = state
657                .extras
658                .iter()
659                .enumerate()
660                .map(|(index, extra)| {
661                    let payer = extra
662                        .payer
663                        .map_or_else(|| "no payer yet".to_owned(), |p| format!("paid by {p:?}"));
664                    format!(
665                        "{}. {} × {} at {}.{:02} EUR, {payer}",
666                        index + 1,
667                        extra.quantity,
668                        extra.description,
669                        extra.unit_price_cents / 100,
670                        extra.unit_price_cents.rem_euclid(100)
671                    )
672                })
673                .collect();
674            held.push(StateField::new("extras", serde_json::json!(extras)));
675        }
676        if let Some(offer) = &state.offer {
677            held.push(StateField::new(
678                "offer",
679                serde_json::json!(format!(
680                    "leg {} on {} {}, {}.{:02} EUR more",
681                    offer.leg,
682                    offer.flight,
683                    offer.departs,
684                    offer.fare_difference_cents / 100,
685                    offer.fare_difference_cents.rem_euclid(100)
686                )),
687            ));
688        }
689        held
690    }
691
692    fn project(&self, case_ref: CaseRef, state: Option<&TripState>) -> ViewOf<Self> {
693        let version = self.version();
694        let Some(state) = state else {
695            return WorkflowView::new(case_ref, version, TripPhase::PreDraft);
696        };
697        let phase = Self::phase_of(state);
698        let view =
699            WorkflowView::new(case_ref, version, phase).with_obligations(state.open_obligations());
700        match phase {
701            TripPhase::AwaitingRebookingConfirmation => {
702                view.with_blocking_interaction(rebooking_requirement(state))
703            }
704            TripPhase::Refused => view.with_notice(refusal_notice(state)),
705            TripPhase::Dispatching | TripPhase::Ticketed => match airline_notice(state) {
706                Some(notice) => view.with_notice(notice),
707                None => view,
708            },
709            TripPhase::Withdrawn => view.with_outcome(TripOutcome::Withdrawn),
710            TripPhase::Notified => view.with_outcome(TripOutcome::Notified),
711            TripPhase::NotNotified => view.with_outcome(TripOutcome::NotNotified),
712            TripPhase::PreDraft | TripPhase::Collecting => view,
713        }
714    }
715
716    fn summary(&self) -> Option<String> {
717        Some(String::from(
718            "Trips: the disruption case of a booking, from its extras to a rebooking.",
719        ))
720    }
721
722    fn noun(&self) -> Option<LocalizedText> {
723        Some(LocalizedText::new("trip").with("it", "viaggio"))
724    }
725
726    fn glossary(&self) -> Vec<GlossaryTerm> {
727        vec![GlossaryTerm::new(
728            "fare difference",
729            "what the new flight costs beyond the ticket already paid",
730        )]
731    }
732
733    fn operations(&self, view: &ViewOf<Self>) -> Vec<OperationSpec> {
734        super::super::in_italian(Self::offered(view), ITALIAN)
735    }
736
737    /// Guidance that changes with the phase: while a case is being filled in, what
738    /// a kept leg means; once the rebooking card is up, to read the message as an
739    /// answer to it.
740    fn briefing(&self, view: &ViewOf<Self>) -> Option<String> {
741        match view.phase {
742            TripPhase::Collecting => Some(String::from(
743                "A leg marked as kept is one the traveler asked to leave as it is: never \
744                 propose changing it. A field that is not open as an obligation has already \
745                 been settled; do not ask for it again.",
746            )),
747            TripPhase::AwaitingRebookingConfirmation => Some(String::from(
748                "This trip is waiting on the rebooking card. Read the message as an answer to \
749                 that card, not as a new instruction, unless it plainly changes a field.",
750            )),
751            _ => None,
752        }
753    }
754
755    /// What the sample wants said while it acknowledges and asks.
756    fn transition_briefing(&self, view: &ViewOf<Self>) -> Option<String> {
757        match view.phase {
758            TripPhase::PreDraft => Some(String::from(
759                "There is no case yet. What is needed first is who is travelling: ask for the \
760                 traveler and nothing else.",
761            )),
762            TripPhase::Collecting => Some(String::from(
763                "Ask for one thing at a time, in the order the obligations are listed. Do not \
764                 read the trip back unless the user asked for it.",
765            )),
766            TripPhase::AwaitingRebookingConfirmation => Some(String::from(
767                "The rebooking card below your text already shows the flight and the fare. Do \
768                 not repeat them.",
769            )),
770            _ => None,
771        }
772    }
773
774    /// And something else for the stage that answers.
775    fn answer_briefing(&self, view: &ViewOf<Self>) -> Option<String> {
776        match view.phase {
777            TripPhase::Collecting => Some(String::from(
778                "Amounts on this trip are in whole cents; say them as currency.",
779            )),
780            _ => None,
781        }
782    }
783
784    /// The itinerary a quoted rebooking has, declared from the projection so it is
785    /// there on a turn where the user asks instead of clicking.
786    fn artifacts(&self, view: &ViewOf<Self>) -> Vec<ArtifactRef> {
787        if !matches!(
788            view.phase,
789            TripPhase::AwaitingRebookingConfirmation | TripPhase::Ticketed
790        ) {
791            return Vec::new();
792        }
793        vec![ArtifactRef {
794            artifact_id: format!("itinerary:{}", view.case_ref.case_id),
795            kind: "itinerary_pdf".to_owned(),
796            label: LocalizedText::new("Itinerary preview").with("it", "Anteprima itinerario"),
797            uri: None,
798            media_type: Some("application/pdf".to_owned()),
799        }]
800    }
801
802    /// What the destructive confirmation is about, in words that name the thing.
803    fn confirmation_subject(
804        &self,
805        state: Option<&TripState>,
806        _view: &ViewOf<Self>,
807        act: &ResolvedAct,
808    ) -> Option<ConfirmationSubject> {
809        let ResolvedActKind::ApplyOperation { operation } = &act.kind else {
810            return None;
811        };
812        if operation.as_str() != operations::WITHDRAW {
813            return None;
814        }
815        let who = state.and_then(|state| state.traveler.as_ref()).map_or_else(
816            || String::from("this case"),
817            |traveler| format!("the case for {}", traveler.display_name),
818        );
819        Some(ConfirmationSubject::asking(LocalizedText::new(format!(
820            "Withdraw {who}?"
821        ))))
822    }
823
824    /// The payers this workflow accepts, and no others.
825    fn enumerations(&self, _view: &ViewOf<Self>) -> Vec<DomainEnumeration> {
826        vec![
827            DomainEnumeration::new(
828                "extras.payer",
829                vec![
830                    EnumeratedValue::new("traveler", LocalizedText::new("The traveler")),
831                    EnumeratedValue::new("company", LocalizedText::new("The company")),
832                    EnumeratedValue::new("airline", LocalizedText::new("The airline")),
833                ],
834            )
835            .with_preamble(LocalizedText::new("An extra can be paid by one of these.")),
836        ]
837    }
838
839    /// A complete trip can take another extra or be rebooked when a quote is in;
840    /// one still owing something offers nothing, because what it owes is asked first.
841    fn next_steps(&self, view: &ViewOf<Self>) -> Vec<LocalizedText> {
842        if view.phase != TripPhase::Collecting || !view.obligations.is_empty() {
843            return Vec::new();
844        }
845        vec![
846            LocalizedText::new("Add another extra.").with("it", "Aggiungere un altro extra."),
847            LocalizedText::new("Rebook the quoted flight: a card asks to confirm first.").with(
848                "it",
849                "Cambiare il volo proposto: prima una scheda chiede conferma.",
850            ),
851        ]
852    }
853
854    fn compile_act(
855        &self,
856        state: Option<&TripState>,
857        _view: &ViewOf<Self>,
858        act: &ResolvedAct,
859    ) -> Result<Vec<TripCommand>, DomainRejection> {
860        match &act.kind {
861            ResolvedActKind::StartWorkflow => Ok(vec![TripCommand::Open]),
862            ResolvedActKind::ApplyOperation { operation } => {
863                Self::compile_operation(state, operation, &act.arguments)
864            }
865            _ => Err(reject(rejection::UNSUPPORTED_ACT)),
866        }
867    }
868
869    /// Why nothing changed: the name already there.
870    fn nothing_changed(
871        &self,
872        state: Option<&TripState>,
873        act: &ResolvedAct,
874    ) -> Option<LocalizedText> {
875        let ResolvedActKind::ApplyOperation { operation } = &act.kind else {
876            return None;
877        };
878        if operation.as_str() != operations::SET_NAME {
879            return None;
880        }
881        let name = state?.name.as_deref()?;
882        Some(
883            LocalizedText::new(format!("The trip is already called \"{name}\".")).with(
884                Locale::from("it-IT"),
885                format!("Il viaggio si chiama già «{name}»."),
886            ),
887        )
888    }
889
890    fn command_policy(&self, state: Option<&TripState>, command: &TripCommand) -> CommandPolicy {
891        match command {
892            // Choosing the first traveler replaces nobody: it needs no review.
893            TripCommand::ChangeTraveler { .. }
894                if !self.with_cards || state.is_some_and(|state| state.traveler.is_none()) =>
895            {
896                CommandPolicy::low_risk()
897            }
898            TripCommand::Withdraw if !self.with_cards => CommandPolicy::low_risk(),
899            TripCommand::Rebook => CommandPolicy {
900                risk: RiskClass::ExternalRegulated,
901                confirmation: ConfirmationPolicy::ExplicitClick,
902                atomicity: AtomicityScope::ExternalSaga {
903                    saga: "trip.rebooking".to_owned(),
904                },
905                claim_mode: ClaimMode::ServerReceiptOnly,
906            },
907            TripCommand::Withdraw => CommandPolicy {
908                risk: RiskClass::Destructive,
909                confirmation: ConfirmationPolicy::ExplicitClick,
910                atomicity: AtomicityScope::PerCase,
911                claim_mode: ClaimMode::ServerReceiptOnly,
912            },
913            TripCommand::ChangeTraveler { .. } => CommandPolicy {
914                risk: RiskClass::SensitiveDataChange,
915                confirmation: ConfirmationPolicy::ReviewCard,
916                atomicity: AtomicityScope::PerCase,
917                claim_mode: ClaimMode::EventReferencedParaphrase,
918            },
919            TripCommand::RecordAirlineOutcome { .. } => CommandPolicy {
920                risk: RiskClass::ExternalRegulated,
921                confirmation: ConfirmationPolicy::None,
922                atomicity: AtomicityScope::PerCase,
923                claim_mode: ClaimMode::ServerReceiptOnly,
924            },
925            TripCommand::Open
926            | TripCommand::OpenFor { .. }
927            | TripCommand::SetName { .. }
928            | TripCommand::SetTravelDate { .. }
929            | TripCommand::AddExtra { .. }
930            | TripCommand::AssignPayer { .. }
931            | TripCommand::ChangeExtra { .. }
932            | TripCommand::ProtectLeg { .. }
933            | TripCommand::Requote { .. }
934            | TripCommand::RequestRebooking { .. } => CommandPolicy::low_risk(),
935        }
936    }
937
938    fn validate_command(
939        &self,
940        state: Option<&TripState>,
941        command: &TripCommand,
942    ) -> Result<(), DomainRejection> {
943        validate(state, command)
944    }
945
946    fn receipts(
947        &self,
948        events: &[ReceiptEvent<TripEvent>],
949        locale: &Locale,
950    ) -> Vec<OperationalReceipt> {
951        let _ = locale;
952        events
953            .iter()
954            .map(|event| match event {
955                ReceiptEvent::Committed(committed) => committed_receipt(committed),
956                ReceiptEvent::Redacted(redacted) => redacted_receipt(redacted),
957            })
958            .collect()
959    }
960
961    fn build_interaction(
962        &self,
963        state: Option<&TripState>,
964        view: &ViewOf<Self>,
965        requirement: &InteractionRequirement,
966    ) -> Result<InteractionSpec, DomainRejection> {
967        let mut spec = requirement.to_spec(view.case_ref.clone());
968        if let Some(state) = state {
969            // The card names what it was drawn from: a new quote changes both.
970            let preview =
971                canonical_digest(state).map_err(|_| reject(rejection::INVALID_ARGUMENTS))?;
972            spec.payload = spec.payload.with_metadata(serde_json::json!({
973                "preview_hash": preview.as_str(),
974                "bound_revision": view.case_ref.expected_revision.value(),
975            }));
976        }
977        Ok(spec)
978    }
979}
980
981impl TripWorkflow {
982    /// The operations a view offers, with their summaries in English.
983    fn offered(view: &ViewOf<Self>) -> Vec<OperationSpec> {
984        // Withdrawing takes only a record already in view: the sample's coverage of
985        // `RequiresCatalogedCase`.
986        let withdraw = operation(
987            operations::WITHDRAW,
988            "Withdraw the case before a rebooking is sent.",
989            TargetPolicy::RequiresCatalogedCase,
990        );
991        let request_rebooking = leg_argument(operation(
992            operations::REQUEST_REBOOKING,
993            "Show the rebooking card for the flight the airline quoted for a leg.",
994            TargetPolicy::RequiresExistingCase,
995        ));
996        match view.phase {
997            TripPhase::PreDraft => vec![
998                operation(
999                    operations::OPEN,
1000                    "Open a new trip, the disruption case of one booking, for its traveler when named.",
1001                    TargetPolicy::NewCaseOnly,
1002                )
1003                .arguments::<OpenArgs>()
1004                .argument("traveler", |a| {
1005                    a.label("traveler")
1006                        .label_in("it-IT", "viaggiatore")
1007                        .record("traveler")
1008                }),
1009            ],
1010            TripPhase::Collecting | TripPhase::Refused => {
1011                let mut offered = editing_operations();
1012                // The first traveler is chosen from the traveler records; changing it
1013                // later is the reviewed change.
1014                if view.obligations.contains(&TripObligation::SelectTraveler) {
1015                    offered.retain(|spec| spec.key.as_str() != operations::CHANGE_TRAVELER);
1016                    offered.push(set_traveler());
1017                }
1018                offered.push(request_rebooking);
1019                offered.push(withdraw);
1020                offered
1021            }
1022            TripPhase::AwaitingRebookingConfirmation => {
1023                let mut offered = editing_operations();
1024                // Only the card's own button runs this: a user asking to see the
1025                // confirmation again gets the recap, not a bare "Confirm?".
1026                offered.push(
1027                    operation(
1028                        operations::REBOOK,
1029                        "Send the rebooking to the airline.",
1030                        TargetPolicy::RequiresCatalogedCase,
1031                    )
1032                    .card_only(),
1033                );
1034                // The card's «Keep my flight» records that it was asked; only its
1035                // button runs it.
1036                offered.push(
1037                    operation(
1038                        operations::ACKNOWLEDGE_CARD,
1039                        "Acknowledge the card currently on screen.",
1040                        TargetPolicy::RequiresCatalogedCase,
1041                    )
1042                    .card_only(),
1043                );
1044                offered.push(withdraw);
1045                offered
1046            }
1047            TripPhase::Dispatching
1048            | TripPhase::Ticketed
1049            | TripPhase::Notified
1050            | TripPhase::NotNotified
1051            | TripPhase::Withdrawn => Vec::new(),
1052        }
1053    }
1054}
1055
1056/// What each operation does, in Italian.
1057const ITALIAN: &[(&str, &str)] = &[
1058    (
1059        operations::OPEN,
1060        "Apre un nuovo viaggio, la pratica di una prenotazione, per il viaggiatore se è nominato.",
1061    ),
1062    (
1063        operations::SET_NAME,
1064        "Dà un nome al viaggio: come lo chiama il viaggiatore, in poche parole.",
1065    ),
1066    (
1067        operations::SET_TRAVEL_DATE,
1068        "Imposta il giorno in cui il viaggiatore preferisce volare.",
1069    ),
1070    (
1071        operations::ADD_EXTRA,
1072        "Aggiunge un extra al viaggio, cos'è, quanti e a che prezzo; ogni extra si aggiunge da solo.",
1073    ),
1074    (
1075        operations::ASSIGN_PAYER,
1076        "Dice chi paga un extra: il viaggiatore, l'azienda o la compagnia aerea.",
1077    ),
1078    (
1079        operations::CHANGE_EXTRA,
1080        "Cambia un extra già nel viaggio: descrizione, quantità o prezzo unitario.",
1081    ),
1082    (
1083        operations::SET_TRAVELER,
1084        "Sceglie il viaggiatore della pratica.",
1085    ),
1086    (
1087        operations::CHANGE_TRAVELER,
1088        "Mette un altro viaggiatore sul viaggio.",
1089    ),
1090    (
1091        operations::PROTECT_LEG,
1092        "Blocca una tratta della prenotazione perché niente la cambi, chiesto da solo; «non toccare il ritorno» detto mentre si cambia l'andata è una condizione del turno, non un blocco.",
1093    ),
1094    (
1095        operations::REQUEST_REBOOKING,
1096        "Mostra la scheda di cambio per il volo proposto dalla compagnia aerea su una tratta.",
1097    ),
1098    (operations::REBOOK, "Invia il cambio alla compagnia aerea."),
1099    (
1100        operations::WITHDRAW,
1101        "Ritira la pratica prima che un cambio sia inviato.",
1102    ),
1103    (
1104        operations::ACKNOWLEDGE_CARD,
1105        "Prende atto della scheda sullo schermo.",
1106    ),
1107];
1108
1109/// Renders one committed event as a receipt, copying the real event id so the
1110/// claim is backed by the ledger.
1111fn committed_receipt(committed: &CommittedEvent<TripEvent>) -> OperationalReceipt {
1112    let event_ids = vec![committed.event_id];
1113    let status_code = committed.payload.event_type().to_owned();
1114    let (severity, title, body) = receipt_copy(&committed.payload);
1115    OperationalReceipt {
1116        receipt_id: ReceiptId::derive(&event_ids, &status_code),
1117        event_ids,
1118        severity,
1119        title,
1120        body,
1121        status_code,
1122        artifact_refs: Vec::new(),
1123    }
1124}
1125
1126/// Renders an event whose payload was erased.
1127///
1128/// The receipt still cites the event, so the claim guard still backs it; what the
1129/// copy must not do is describe a change whose values are gone. The event type is
1130/// kept out of the copy too: a user is owed a sentence, not a symbol.
1131fn redacted_receipt(redacted: &RedactedEvent) -> OperationalReceipt {
1132    let event_ids = vec![redacted.event_id];
1133    let status_code = "trip.detail_erased".to_owned();
1134    OperationalReceipt {
1135        receipt_id: ReceiptId::derive(&event_ids, &status_code),
1136        event_ids,
1137        severity: ReceiptSeverity::Info,
1138        title: LocalizedText::new("Detail erased").with("it", "Dettaglio cancellato"),
1139        body: LocalizedText::new(
1140            "This change is still on record; the detail of what changed was erased.",
1141        )
1142        .with(
1143            "it",
1144            "Questa modifica resta a registro; il dettaglio di cosa è cambiato è stato cancellato.",
1145        ),
1146        status_code,
1147        artifact_refs: Vec::new(),
1148    }
1149}
1150
1151/// Server-authored copy, in English with an Italian translation.
1152fn receipt_copy(event: &TripEvent) -> (ReceiptSeverity, LocalizedText, LocalizedText) {
1153    match event {
1154        TripEvent::Opened => (
1155            ReceiptSeverity::Success,
1156            LocalizedText::new("Case opened").with("it", "Pratica aperta"),
1157            LocalizedText::new("A new disruption case is open.")
1158                .with("it", "È aperta una nuova pratica."),
1159        ),
1160        TripEvent::NameSet { value } => (
1161            ReceiptSeverity::Success,
1162            LocalizedText::new("Trip named").with("it", "Nome impostato"),
1163            LocalizedText::new(format!("The trip is now called \"{value}\"."))
1164                .with("it", format!("Il viaggio ora si chiama \"{value}\".")),
1165        ),
1166        TripEvent::TravelDateSet { value } => (
1167            ReceiptSeverity::Success,
1168            LocalizedText::new("Travel date set").with("it", "Data impostata"),
1169            LocalizedText::new(format!("The traveler would rather fly on {value}.")).with(
1170                "it",
1171                format!("Il viaggiatore preferisce volare il {value}."),
1172            ),
1173        ),
1174        TripEvent::ExtraAdded {
1175            description,
1176            quantity,
1177            unit_price_cents,
1178            ..
1179        } => (
1180            ReceiptSeverity::Success,
1181            LocalizedText::new("Extra added").with("it", "Extra aggiunto"),
1182            LocalizedText::new(format!(
1183                "{quantity} x \"{description}\" at {} each.",
1184                money(*unit_price_cents, "en")
1185            ))
1186            .with(
1187                "it",
1188                format!(
1189                    "{quantity} x \"{description}\" a {} l'uno.",
1190                    money(*unit_price_cents, "it")
1191                ),
1192            ),
1193        ),
1194        TripEvent::PayerAssigned { payer, .. } => (
1195            ReceiptSeverity::Success,
1196            LocalizedText::new("Payer set").with("it", "Pagante impostato"),
1197            LocalizedText::new(format!("Paid by {}.", payer_name(*payer, "en")))
1198                .with("it", format!("Lo paga {}.", payer_name(*payer, "it"))),
1199        ),
1200        TripEvent::ExtraChanged {
1201            description,
1202            quantity,
1203            unit_price_cents,
1204            ..
1205        } => {
1206            let mut english = Vec::new();
1207            let mut italian = Vec::new();
1208            if let Some(description) = description {
1209                english.push(format!("described as \"{description}\""));
1210                italian.push(format!("descritto come \"{description}\""));
1211            }
1212            if let Some(quantity) = quantity {
1213                english.push(format!("quantity {quantity}"));
1214                italian.push(format!("quantità {quantity}"));
1215            }
1216            if let Some(cents) = unit_price_cents {
1217                english.push(format!("{} each", money(*cents, "en")));
1218                italian.push(format!("{} l'uno", money(*cents, "it")));
1219            }
1220            (
1221                ReceiptSeverity::Success,
1222                LocalizedText::new("Extra changed").with("it", "Extra modificato"),
1223                LocalizedText::new(format!("The extra is now {}.", english.join(", ")))
1224                    .with("it", format!("L'extra ora è {}.", italian.join(", "))),
1225            )
1226        }
1227        TripEvent::TravelerChanged {
1228            previous_traveler_id: None,
1229            display_name,
1230            ..
1231        } => (
1232            ReceiptSeverity::Success,
1233            LocalizedText::new("Traveler set").with("it", "Viaggiatore impostato"),
1234            LocalizedText::new(format!("The trip is for {}.", sentence_end(display_name))).with(
1235                "it",
1236                format!("Il viaggio è per {}.", sentence_end(display_name)),
1237            ),
1238        ),
1239        TripEvent::TravelerChanged { display_name, .. } => (
1240            ReceiptSeverity::Success,
1241            LocalizedText::new("Traveler changed").with("it", "Viaggiatore cambiato"),
1242            LocalizedText::new(format!(
1243                "{} is now the traveler.",
1244                sentence_end(display_name)
1245            ))
1246            .with("it", format!("Ora viaggia {}.", sentence_end(display_name))),
1247        ),
1248        TripEvent::LegProtected { leg } => (
1249            ReceiptSeverity::Success,
1250            LocalizedText::new("Leg kept").with("it", "Tratta bloccata"),
1251            LocalizedText::new(format!("Leg {leg} stays as it is; nothing will change it.")).with(
1252                "it",
1253                format!("La tratta {leg} resta com'è; niente la cambierà."),
1254            ),
1255        ),
1256        TripEvent::OfferQuoted {
1257            leg,
1258            flight,
1259            fare_difference_cents,
1260            ..
1261        } => (
1262            ReceiptSeverity::Info,
1263            LocalizedText::new("Rebooking quoted").with("it", "Cambio proposto"),
1264            LocalizedText::new(format!(
1265                "The airline quotes {flight} for leg {leg}, {} more.",
1266                money(*fare_difference_cents, "en")
1267            ))
1268            .with(
1269                "it",
1270                format!(
1271                    "La compagnia aerea propone il volo {flight} per la tratta {leg}, {} in più.",
1272                    money(*fare_difference_cents, "it")
1273                ),
1274            ),
1275        ),
1276        TripEvent::RebookingRequested { .. } => (
1277            ReceiptSeverity::Info,
1278            LocalizedText::new("Ready to rebook").with("it", "Pronto per il cambio"),
1279            LocalizedText::new("Confirm the card below to send the rebooking to the airline.")
1280                .with(
1281                    "it",
1282                    "Conferma la scheda qui sotto per inviare il cambio alla compagnia aerea.",
1283                ),
1284        ),
1285        TripEvent::RebookingSent => (
1286            ReceiptSeverity::Success,
1287            LocalizedText::new("Rebooking sent").with("it", "Cambio inviato"),
1288            LocalizedText::new(
1289                "The rebooking was sent to the airline; it is not confirmed until they answer.",
1290            )
1291            .with(
1292                "it",
1293                "Il cambio è stato inviato alla compagnia aerea; non è confermato finché non \
1294                 risponde.",
1295            ),
1296        ),
1297        TripEvent::Withdrawn => (
1298            ReceiptSeverity::Success,
1299            LocalizedText::new("Case withdrawn").with("it", "Pratica ritirata"),
1300            LocalizedText::new("The case was withdrawn before any rebooking was sent.").with(
1301                "it",
1302                "La pratica è stata ritirata prima di inviare un cambio.",
1303            ),
1304        ),
1305        TripEvent::AirlineOutcomeRecorded {
1306            status,
1307            ticket_number,
1308            reason_code,
1309        } => {
1310            let severity = match status {
1311                turnframe_core::event::ExternalStatus::Rejected
1312                | turnframe_core::event::ExternalStatus::NotDelivered => ReceiptSeverity::Warning,
1313                _ => ReceiptSeverity::Success,
1314            };
1315            let detail = ticket_number
1316                .as_deref()
1317                .or(reason_code.as_deref())
1318                .unwrap_or("-");
1319            (
1320                severity,
1321                LocalizedText::new("Airline answered").with("it", "Risposta della compagnia"),
1322                LocalizedText::new(format!("The airline reported {status:?} ({detail}).")).with(
1323                    "it",
1324                    format!("La compagnia aerea ha comunicato {status:?} ({detail})."),
1325                ),
1326            )
1327        }
1328    }
1329}
1330
1331impl PureWorkflow for TripWorkflow {
1332    fn apply(
1333        &self,
1334        state: Option<&TripState>,
1335        command: &TripCommand,
1336    ) -> Result<Applied<TripState, TripEvent>, DomainRejection> {
1337        apply(state, command)
1338    }
1339
1340    fn event_type(&self, event: &TripEvent) -> String {
1341        event.event_type().to_owned()
1342    }
1343}
1344
1345#[cfg(test)]
1346mod tests {
1347    use uuid::Uuid;
1348
1349    use super::*;
1350
1351    fn copy(event: &TripEvent, locale: &str) -> String {
1352        let (_, _, body) = receipt_copy(event);
1353        body.resolve(&Locale::new(locale)).to_owned()
1354    }
1355
1356    #[test]
1357    fn an_extra_change_that_changes_nothing_compiles_to_nothing() {
1358        let state = crate::workflows::trip::model::unassigned_case();
1359        let compiled = TripWorkflow::compile_operation(
1360            Some(&state),
1361            &OperationKey::from(operations::CHANGE_EXTRA),
1362            &serde_json::json!({ "extra": 1 }),
1363        );
1364        assert_eq!(compiled.unwrap(), Vec::new());
1365    }
1366
1367    #[test]
1368    fn an_extra_receipt_shows_its_price_as_money() {
1369        let event = TripEvent::ExtraAdded {
1370            extra_id: Uuid::nil(),
1371            description: "bags".to_owned(),
1372            quantity: 2,
1373            unit_price_cents: 4_050,
1374        };
1375        assert_eq!(copy(&event, "en-GB"), "2 x \"bags\" at €40.50 each.");
1376        assert_eq!(copy(&event, "it"), "2 x \"bags\" a 40,50 € l'uno.");
1377    }
1378
1379    #[test]
1380    fn a_payer_receipt_names_the_payer() {
1381        let event = TripEvent::PayerAssigned {
1382            extra_id: Uuid::nil(),
1383            payer: Payer::Airline,
1384        };
1385        assert_eq!(copy(&event, "en-GB"), "Paid by the airline.");
1386    }
1387
1388    #[test]
1389    fn the_rebooking_card_shows_the_quoted_fare() {
1390        let state = crate::workflows::trip::model::awaiting_rebooking_confirmation();
1391        let body = rebooking_card_body(&state);
1392        assert_eq!(
1393            body.resolve(&Locale::new("en-GB")),
1394            "Leg 1 (FCO→LIS) on AZ612, 2026-10-05 13:10, €84.00 more. Once sent, the airline \
1395             decides."
1396        );
1397    }
1398}