Files
rezervovator/src/backend/cancellation.rs
T

189 lines
8.4 KiB
Rust

//! Cancelling of a booking by the customer, through the link sent in e-mails.
use chrono::{Duration, NaiveDate, NaiveDateTime, NaiveTime};
use crate::backend::data::ReservationState;
/// Longest deadline that can be set, in hours before the start of a booking (30 days)
pub const MAX_CANCEL_HOURS: i32 = 720;
/// Placeholder of the link to the booking page in mail templates
pub const LINK_PLACEHOLDER: &str = "#link#";
/// Whether the customer may cancel a booking
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CancelStatus {
/// It can be cancelled until the given time
Allowed(NaiveDateTime),
AlreadyCanceled,
/// It could be cancelled until the given time, which has passed
TooLate(NaiveDateTime),
}
/// A booking starts with the earliest of its items. One without items (which should not exist)
/// counts from the beginning of its day.
pub fn booking_start(date: NaiveDate, item_starts: &[NaiveTime]) -> NaiveDateTime {
date.and_time(item_starts.iter().min().copied().unwrap_or(NaiveTime::MIN))
}
/// Last moment when the customer may cancel: the given number of hours before the start.
/// A negative or too large setting is treated as the nearest allowed one.
pub fn cancel_deadline(start: NaiveDateTime, cancel_hours: i32) -> NaiveDateTime {
start - Duration::hours(cancel_hours.clamp(0, MAX_CANCEL_HOURS) as i64)
}
/// Bookings waiting for approval and approved ones can be cancelled until the deadline
pub fn cancel_status(state: &ReservationState, start: NaiveDateTime, cancel_hours: i32, now: NaiveDateTime) -> CancelStatus {
let deadline = cancel_deadline(start, cancel_hours);
if *state == ReservationState::Canceled {
CancelStatus::AlreadyCanceled
} else if now < deadline {
CancelStatus::Allowed(deadline)
} else {
CancelStatus::TooLate(deadline)
}
}
/// Address of the page of a booking. The uuid is the only secret that protects it.
pub fn booking_link(base_url: &str, uuid: &str) -> String {
format!("{}/booking/{}", base_url.trim_end_matches('/'), uuid)
}
/// Puts the link to the booking page into the text of an e-mail: in place of every `#link#`.
/// A text without the placeholder gets the link at its end, introduced by the given sentence,
/// when there is one; otherwise it is left as it is.
pub fn insert_link(text: &str, link: &str, intro_when_missing: Option<&str>) -> String {
if text.contains(LINK_PLACEHOLDER) {
return text.replace(LINK_PLACEHOLDER, link)
}
match intro_when_missing {
Some(intro) => format!("{}\n\n{}\n{}", text.trim_end(), intro, link),
None => text.to_string(),
}
}
/// Cleans up the public address from the config: no trailing slash, and only `http(s)://host[:port][/path]`.
/// The address gets into e-mails, so anything suspicious is refused.
pub fn normalize_base_url(url: &str) -> Option<String> {
let url = url.trim().trim_end_matches('/');
let rest = url.strip_prefix("https://").or_else(|| url.strip_prefix("http://"))?;
if rest.is_empty() || rest.starts_with('/')
|| !rest.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | ':' | '/' | '[' | ']')) {
return None
}
Some(url.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
fn date(day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(2026, 10, day).unwrap()
}
fn time(hour: u32, minute: u32) -> NaiveTime {
NaiveTime::from_hms_opt(hour, minute, 0).unwrap()
}
fn at(day: u32, hour: u32, minute: u32) -> NaiveDateTime {
date(day).and_time(time(hour, minute))
}
#[test]
fn booking_starts_with_earliest_item() {
assert_eq!(booking_start(date(20), &[time(18, 0), time(9, 30), time(14, 0)]), at(20, 9, 30));
assert_eq!(booking_start(date(20), &[time(18, 0)]), at(20, 18, 0));
assert_eq!(booking_start(date(20), &[]), at(20, 0, 0));
}
#[test]
fn deadline_is_hours_before_start() {
assert_eq!(cancel_deadline(at(20, 18, 0), 0), at(20, 18, 0));
assert_eq!(cancel_deadline(at(20, 18, 0), 2), at(20, 16, 0));
// Over midnight
assert_eq!(cancel_deadline(at(20, 9, 0), 24), at(19, 9, 0));
assert_eq!(cancel_deadline(at(20, 1, 0), 3), at(19, 22, 0));
}
#[test]
fn deadline_ignores_invalid_settings() {
assert_eq!(cancel_deadline(at(20, 18, 0), -5), at(20, 18, 0));
assert_eq!(cancel_deadline(at(20, 18, 0), i32::MAX), at(20, 18, 0) - Duration::hours(MAX_CANCEL_HOURS as i64));
}
#[test]
fn new_and_approved_bookings_can_be_cancelled_before_deadline() {
for state in [ReservationState::New, ReservationState::Approved] {
assert_eq!(cancel_status(&state, at(20, 18, 0), 0, at(20, 17, 59)), CancelStatus::Allowed(at(20, 18, 0)));
assert_eq!(cancel_status(&state, at(20, 18, 0), 24, at(19, 17, 59)), CancelStatus::Allowed(at(19, 18, 0)));
assert_eq!(cancel_status(&state, at(20, 18, 0), 24, at(10, 0, 0)), CancelStatus::Allowed(at(19, 18, 0)));
}
}
#[test]
fn bookings_cannot_be_cancelled_from_the_deadline_on() {
for state in [ReservationState::New, ReservationState::Approved] {
// Exactly at the start, after it, and in the past
assert_eq!(cancel_status(&state, at(20, 18, 0), 0, at(20, 18, 0)), CancelStatus::TooLate(at(20, 18, 0)));
assert_eq!(cancel_status(&state, at(20, 18, 0), 0, at(20, 18, 1)), CancelStatus::TooLate(at(20, 18, 0)));
assert_eq!(cancel_status(&state, at(20, 18, 0), 0, at(25, 0, 0)), CancelStatus::TooLate(at(20, 18, 0)));
// Inside the deadline, although the booking has not started yet
assert_eq!(cancel_status(&state, at(20, 18, 0), 24, at(19, 18, 0)), CancelStatus::TooLate(at(19, 18, 0)));
assert_eq!(cancel_status(&state, at(20, 18, 0), 24, at(20, 10, 0)), CancelStatus::TooLate(at(19, 18, 0)));
}
}
#[test]
fn cancelled_booking_stays_cancelled() {
assert_eq!(cancel_status(&ReservationState::Canceled, at(20, 18, 0), 0, at(1, 0, 0)), CancelStatus::AlreadyCanceled);
assert_eq!(cancel_status(&ReservationState::Canceled, at(20, 18, 0), 0, at(25, 0, 0)), CancelStatus::AlreadyCanceled);
}
#[test]
fn booking_link_joins_address_and_uuid() {
let uuid = "0b6ff0a4-2f4b-4c57-9d0a-0d4f7f0f3b11";
assert_eq!(booking_link("https://booking.example.com", uuid), format!("https://booking.example.com/booking/{}", uuid));
assert_eq!(booking_link("https://booking.example.com/", uuid), format!("https://booking.example.com/booking/{}", uuid));
assert_eq!(booking_link("http://localhost:3000", uuid), format!("http://localhost:3000/booking/{}", uuid));
}
#[test]
fn insert_link_replaces_placeholder() {
assert_eq!(insert_link("See #link# for details.", "https://x/booking/1", Some("Intro:")), "See https://x/booking/1 for details.");
assert_eq!(insert_link("#link#\n#link#", "L", None), "L\nL");
}
#[test]
fn insert_link_appends_when_placeholder_is_missing() {
assert_eq!(insert_link("Thank you.\n", "https://x/booking/1", Some("Your booking:")),
"Thank you.\n\nYour booking:\nhttps://x/booking/1");
assert_eq!(insert_link("", "L", Some("Intro:")), "\n\nIntro:\nL");
}
#[test]
fn insert_link_leaves_text_without_intro() {
assert_eq!(insert_link("Thank you.\n", "https://x/booking/1", None), "Thank you.\n");
}
#[test]
fn base_url_is_normalized() {
assert_eq!(normalize_base_url("https://booking.example.com"), Some("https://booking.example.com".to_string()));
assert_eq!(normalize_base_url(" https://booking.example.com/ "), Some("https://booking.example.com".to_string()));
assert_eq!(normalize_base_url("http://localhost:3000"), Some("http://localhost:3000".to_string()));
assert_eq!(normalize_base_url("https://example.com/app"), Some("https://example.com/app".to_string()));
assert_eq!(normalize_base_url("http://[::1]:3000"), Some("http://[::1]:3000".to_string()));
}
#[test]
fn base_url_rejects_other_values() {
for url in ["", "booking.example.com", "ftp://example.com", "https://", "https:///path", "javascript:alert(1)",
"https://example.com/?x=1", "https://exa mple.com", "https://example.com\nBcc: x@y.z", "https://user@example.com",
"https://example.com/<script>"] {
assert_eq!(normalize_base_url(url), None, "url: {:?}", url);
}
}
}