use crate::types::CategoryId;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Classification {
Income,
Expense,
}
impl fmt::Display for Classification {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Income => write!(f, "income"),
Self::Expense => write!(f, "expense"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParseClassificationError(String);
impl fmt::Display for ParseClassificationError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Invalid classification: {}", self.0)
}
}
impl std::error::Error for ParseClassificationError {}
impl std::str::FromStr for Classification {
type Err = ParseClassificationError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"income" => Ok(Self::Income),
"expense" => Ok(Self::Expense),
_ => Err(ParseClassificationError(s.to_string())),
}
}
}
impl TryFrom<&str> for Classification {
type Error = ParseClassificationError;
fn try_from(value: &str) -> Result<Self, Self::Error> {
value.parse()
}
}
impl TryFrom<String> for Classification {
type Error = ParseClassificationError;
fn try_from(value: String) -> Result<Self, Self::Error> {
value.parse()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "strict", serde(deny_unknown_fields))]
pub struct Category {
pub id: CategoryId,
pub name: String,
pub color: String,
pub icon: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "strict", serde(deny_unknown_fields))]
pub struct CategoryParent {
pub id: CategoryId,
pub name: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "strict", serde(deny_unknown_fields))]
pub struct CategoryDetail {
pub id: CategoryId,
pub name: String,
pub color: String,
pub icon: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent: Option<CategoryParent>,
pub subcategories_count: u32,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub classification: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "strict", serde(deny_unknown_fields))]
pub struct CategoryCollection {
pub categories: Vec<CategoryDetail>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "strict", serde(deny_unknown_fields))]
pub(crate) struct CreateCategoryRequest {
pub category: CreateCategoryData,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "strict", serde(deny_unknown_fields))]
pub(crate) struct CreateCategoryData {
pub name: String,
pub color: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lucide_icon: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_id: Option<CategoryId>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "strict", serde(deny_unknown_fields))]
pub(crate) struct UpdateCategoryRequest {
pub category: UpdateCategoryData,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "strict", serde(deny_unknown_fields))]
pub(crate) struct UpdateCategoryData {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub color: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lucide_icon: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_id: Option<CategoryId>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn deserializes_new_shape_without_classification() {
let json = r##"{
"id": "65588a1e-8a33-4dfb-9ea4-1aa7c4a6d855",
"name": "Groceries",
"color": "#fd7f6f",
"icon": "shapes",
"parent": null,
"subcategories_count": 0,
"created_at": "2026-05-04T10:10:12Z",
"updated_at": "2026-05-04T10:10:12Z"
}"##;
let detail: CategoryDetail = serde_json::from_str(json).expect("deserialise new shape");
assert_eq!(detail.classification, None);
assert_eq!(detail.name, "Groceries");
}
#[test]
fn deserializes_legacy_shape_with_classification() {
let json = r##"{
"id": "65588a1e-8a33-4dfb-9ea4-1aa7c4a6d855",
"name": "Groceries",
"classification": "expense",
"color": "#fd7f6f",
"icon": "shapes",
"parent": null,
"subcategories_count": 0,
"created_at": "2026-05-04T10:10:12Z",
"updated_at": "2026-05-04T10:10:12Z"
}"##;
let detail: CategoryDetail = serde_json::from_str(json).expect("deserialise legacy shape");
assert_eq!(detail.classification.as_deref(), Some("expense"));
}
}