use chrono::{DateTime, Utc};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use stateset_primitives::{CurrencyCode, PriceScheduleId, ProductId};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PriceSchedule {
pub id: PriceScheduleId,
pub name: String,
pub code: Option<String>,
pub currency: CurrencyCode,
pub starts_at: Option<DateTime<Utc>>,
pub ends_at: Option<DateTime<Utc>>,
pub is_active: bool,
pub priority: i32,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
impl PriceSchedule {
#[must_use]
pub fn is_effective_at(&self, now: DateTime<Utc>) -> bool {
if !self.is_active {
return false;
}
let after_start = self.starts_at.is_none_or(|s| now >= s);
let before_end = self.ends_at.is_none_or(|e| now <= e);
after_start && before_end
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PriceScheduleEntry {
pub price_schedule_id: PriceScheduleId,
pub product_id: ProductId,
pub price: Decimal,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreatePriceSchedule {
pub name: String,
pub code: Option<String>,
pub currency: Option<CurrencyCode>,
pub starts_at: Option<DateTime<Utc>>,
pub ends_at: Option<DateTime<Utc>>,
#[serde(default)]
pub priority: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct UpdatePriceSchedule {
pub name: Option<String>,
pub code: Option<String>,
pub starts_at: Option<DateTime<Utc>>,
pub ends_at: Option<DateTime<Utc>>,
pub is_active: Option<bool>,
pub priority: Option<i32>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PriceScheduleFilter {
pub is_active: Option<bool>,
pub limit: Option<u32>,
pub offset: Option<u32>,
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{Duration, TimeZone};
use rust_decimal_macros::dec;
fn at(y: i32, m: u32, d: u32) -> DateTime<Utc> {
Utc.with_ymd_and_hms(y, m, d, 12, 0, 0).unwrap()
}
fn make(
starts_at: Option<DateTime<Utc>>,
ends_at: Option<DateTime<Utc>>,
is_active: bool,
) -> PriceSchedule {
PriceSchedule {
id: PriceScheduleId::new(),
name: "Sale".into(),
code: None,
currency: CurrencyCode::USD,
starts_at,
ends_at,
is_active,
priority: 0,
created_at: Utc::now(),
updated_at: Utc::now(),
}
}
#[test]
fn effective_within_window() {
let s = make(Some(at(2026, 6, 1)), Some(at(2026, 6, 30)), true);
assert!(s.is_effective_at(at(2026, 6, 15)));
assert!(!s.is_effective_at(at(2026, 5, 31)));
assert!(!s.is_effective_at(at(2026, 7, 1)));
}
#[test]
fn boundaries_inclusive() {
let start = at(2026, 6, 1);
let end = at(2026, 6, 30);
let s = make(Some(start), Some(end), true);
assert!(s.is_effective_at(start));
assert!(s.is_effective_at(end));
assert!(!s.is_effective_at(end + Duration::seconds(1)));
}
#[test]
fn inactive_never_effective() {
let s = make(None, None, false);
assert!(!s.is_effective_at(at(2026, 6, 15)));
}
#[test]
fn open_ended_windows() {
assert!(make(None, None, true).is_effective_at(at(2026, 1, 1)));
let s = make(Some(at(2026, 6, 1)), None, true);
assert!(s.is_effective_at(at(2030, 1, 1)));
assert!(!s.is_effective_at(at(2025, 1, 1)));
}
#[test]
fn entry_holds_price() {
let entry = PriceScheduleEntry {
price_schedule_id: PriceScheduleId::new(),
product_id: ProductId::new(),
price: dec!(19.99),
created_at: Utc::now(),
updated_at: Utc::now(),
};
assert_eq!(entry.price, dec!(19.99));
}
}