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