Skip to main content

turnframe_test/workflows/trip/
state.rs

1//! Persisted state, phases, obligations and outcomes of the trip sample.
2
3use chrono::NaiveDate;
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6use turnframe_core::event::ExternalStatus;
7use uuid::Uuid;
8
9/// Who pays for one extra. An extra without a payer is an open obligation,
10/// which is what makes the obligation parameterized.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
12#[serde(rename_all = "snake_case")]
13#[non_exhaustive]
14pub enum Payer {
15    /// The traveler pays and is not reimbursed.
16    Traveler,
17    /// The traveler's company pays.
18    Company,
19    /// The airline pays, because the disruption is its own.
20    Airline,
21}
22
23/// An extra as the user describes it, before the domain gives it an identifier.
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
25#[serde(deny_unknown_fields)]
26pub struct NewExtra {
27    /// What the extra is.
28    pub description: String,
29    /// How many, at least one.
30    pub quantity: u32,
31    /// Price of one in cents, never negative.
32    pub unit_price_cents: i64,
33    /// Who pays for it, when it is said as it is added.
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub payer: Option<Payer>,
36}
37
38/// An extra the case adds to the booking: a bag, a seat, a meal, a night.
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40pub struct Extra {
41    /// Stable identifier; the parameter of [`TripObligation::AssignPayer`].
42    pub extra_id: Uuid,
43    /// What the extra is.
44    pub description: String,
45    /// How many.
46    pub quantity: u32,
47    /// Price of one in cents.
48    pub unit_price_cents: i64,
49    /// Who pays; `None` while the obligation is open.
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub payer: Option<Payer>,
52}
53
54impl Extra {
55    /// Total of the extra in cents, saturating instead of overflowing.
56    #[must_use]
57    pub fn total_cents(&self) -> i64 {
58        i64::from(self.quantity).saturating_mul(self.unit_price_cents)
59    }
60}
61
62/// Whether a leg flies as booked.
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
64#[serde(rename_all = "snake_case")]
65#[non_exhaustive]
66pub enum LegStatus {
67    /// It flies as booked.
68    #[default]
69    OnTime,
70    /// It flies late.
71    Delayed,
72    /// It does not fly.
73    Cancelled,
74}
75
76/// One flight of the booking.
77#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
78pub struct Leg {
79    /// Its number in the booking, from 1.
80    pub number: u32,
81    /// The flight, such as `AZ610`.
82    pub flight: String,
83    /// Where it leaves from.
84    pub from: String,
85    /// Where it lands.
86    pub to: String,
87    /// When it leaves, as the booking shows it.
88    pub departs: String,
89    /// Whether it flies as booked.
90    pub status: LegStatus,
91    /// Whether the traveler asked to keep it as it is; a protected leg is never changed.
92    #[serde(default)]
93    pub protected: bool,
94}
95
96/// The rebooking the airline quoted for one leg.
97#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
98pub struct Offer {
99    /// The leg it replaces.
100    pub leg: u32,
101    /// The new flight.
102    pub flight: String,
103    /// When the new flight leaves.
104    pub departs: String,
105    /// What it costs beyond the ticket already paid, in cents.
106    pub fare_difference_cents: i64,
107}
108
109/// The traveler the case is for.
110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
111pub struct TripTraveler {
112    /// Identifier in the application's traveler registry.
113    pub traveler_id: Uuid,
114    /// Server-authored label, safe to show on a card.
115    pub display_name: String,
116}
117
118/// The lifecycle status stored on the case.
119///
120/// The status is persisted; the [`TripPhase`] is projected from it together
121/// with the open obligations, so the two never drift.
122#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
123#[serde(rename_all = "snake_case")]
124#[non_exhaustive]
125pub enum TripStatus {
126    /// Being filled in.
127    #[default]
128    Draft,
129    /// A rebooking is quoted, waiting for the user to confirm it.
130    AwaitingRebookingConfirmation,
131    /// Sent to the airline; no local edit may happen.
132    Rebooking,
133    /// The airline confirmed and issued the new ticket.
134    Ticketed,
135    /// The airline refused the rebooking, and another can be asked for.
136    Refused,
137    /// The traveler was sent the new ticket.
138    Notified,
139    /// The traveler could not be reached; the ticket stands as issued.
140    NotNotified,
141    /// Withdrawn before a rebooking left the system.
142    Withdrawn,
143}
144
145impl TripStatus {
146    /// Returns `true` while the case may still be edited.
147    #[must_use]
148    pub fn is_editable(self) -> bool {
149        matches!(
150            self,
151            Self::Draft | Self::AwaitingRebookingConfirmation | Self::Refused
152        )
153    }
154
155    /// Returns `true` once a rebooking has left the system, so no local edit or
156    /// withdrawal can undo it.
157    #[must_use]
158    pub fn is_sent(self) -> bool {
159        matches!(
160            self,
161            Self::Rebooking | Self::Ticketed | Self::Notified | Self::NotNotified
162        )
163    }
164}
165
166/// The persisted disruption case of one booking.
167#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
168#[serde(deny_unknown_fields)]
169pub struct TripState {
170    /// The traveler, once chosen.
171    #[serde(default, skip_serializing_if = "Option::is_none")]
172    pub traveler: Option<TripTraveler>,
173    /// What the traveler calls this trip.
174    #[serde(default, skip_serializing_if = "Option::is_none")]
175    pub name: Option<String>,
176    /// The day the traveler would rather fly.
177    #[serde(default, skip_serializing_if = "Option::is_none")]
178    pub travel_date: Option<NaiveDate>,
179    /// The flights of the booking, in order.
180    #[serde(default)]
181    pub legs: Vec<Leg>,
182    /// The extras, in the order they were added.
183    #[serde(default)]
184    pub extras: Vec<Extra>,
185    /// The rebooking the airline quoted, if any.
186    #[serde(default, skip_serializing_if = "Option::is_none")]
187    pub offer: Option<Offer>,
188    /// Lifecycle status.
189    pub status: TripStatus,
190    /// Where the rebooking stands with the airline, never collapsed into "done".
191    #[serde(default, skip_serializing_if = "Option::is_none")]
192    pub external_status: Option<ExternalStatus>,
193    /// The new ticket's number, once the airline issued it.
194    #[serde(default, skip_serializing_if = "Option::is_none")]
195    pub ticket_number: Option<String>,
196    /// Stable reason code of the airline's last refusal, never free text.
197    #[serde(default, skip_serializing_if = "Option::is_none")]
198    pub refusal_code: Option<String>,
199}
200
201impl TripState {
202    /// The obligations open in this state, in a stable order.
203    ///
204    /// The per-extra payer obligation is parameterized by the extra's identifier,
205    /// so two extras without a payer are two distinct obligations.
206    #[must_use]
207    pub fn open_obligations(&self) -> Vec<TripObligation> {
208        if !self.status.is_editable() {
209            return Vec::new();
210        }
211        let mut obligations = Vec::new();
212        if self.traveler.is_none() {
213            obligations.push(TripObligation::SelectTraveler);
214        }
215        for extra in &self.extras {
216            if extra.payer.is_none() {
217                obligations.push(TripObligation::AssignPayer {
218                    extra_id: extra.extra_id,
219                });
220            }
221        }
222        if self.name.is_none() {
223            obligations.push(TripObligation::SetName);
224        }
225        if self.travel_date.is_none() {
226            obligations.push(TripObligation::SetTravelDate);
227        }
228        obligations
229    }
230
231    /// Returns `true` when nothing is missing and a rebooking may be asked for.
232    #[must_use]
233    pub fn is_complete(&self) -> bool {
234        self.status.is_editable() && self.open_obligations().is_empty()
235    }
236
237    /// Total of every extra in cents.
238    #[must_use]
239    pub fn extras_total_cents(&self) -> i64 {
240        self.extras.iter().fold(0_i64, |total, extra| {
241            total.saturating_add(extra.total_cents())
242        })
243    }
244
245    /// The extra with this identifier, if any.
246    #[must_use]
247    pub fn extra(&self, extra_id: Uuid) -> Option<&Extra> {
248        self.extras.iter().find(|extra| extra.extra_id == extra_id)
249    }
250
251    /// The leg with this number, if any.
252    #[must_use]
253    pub fn leg(&self, number: u32) -> Option<&Leg> {
254        self.legs.iter().find(|leg| leg.number == number)
255    }
256}
257
258/// Exactly one lifecycle phase.
259#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
260#[serde(rename_all = "snake_case")]
261#[non_exhaustive]
262pub enum TripPhase {
263    /// The case does not exist yet.
264    PreDraft,
265    /// Obligations are open and the user is filling them in.
266    Collecting,
267    /// A rebooking is quoted; its card is waiting for a click.
268    AwaitingRebookingConfirmation,
269    /// The airline has the rebooking.
270    Dispatching,
271    /// The airline refused it, and another can be asked for.
272    Refused,
273    /// Ticketed, waiting for the traveler to be told.
274    Ticketed,
275    /// The traveler has the new ticket.
276    Notified,
277    /// The traveler could not be reached.
278    NotNotified,
279    /// Withdrawn before a rebooking was sent.
280    Withdrawn,
281}
282
283/// An open obligation, possibly parameterized.
284#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
285#[serde(rename_all = "snake_case")]
286#[non_exhaustive]
287pub enum TripObligation {
288    /// No traveler chosen yet.
289    SelectTraveler,
290    /// One specific extra has no payer.
291    AssignPayer {
292        /// The extra that needs one.
293        extra_id: Uuid,
294    },
295    /// No name yet.
296    SetName,
297    /// No travel date yet.
298    SetTravelDate,
299}
300
301/// A terminal outcome, present only when the case is really complete.
302#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
303#[serde(rename_all = "snake_case")]
304#[non_exhaustive]
305pub enum TripOutcome {
306    /// Withdrawn before a rebooking was sent.
307    Withdrawn,
308    /// The traveler has the new ticket.
309    Notified,
310    /// Ticketed but the traveler was never reached.
311    NotNotified,
312}