Skip to main content

sure_client_rs/models/
transaction.rs

1use crate::types::{AccountId, CategoryId, MerchantId, TagId, TransactionId};
2use chrono::{DateTime, Utc};
3use rust_decimal::Decimal;
4use serde::{Deserialize, Serialize};
5
6/// Account information
7#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
8#[cfg_attr(feature = "strict", serde(deny_unknown_fields))]
9pub struct Account {
10    /// Unique identifier
11    pub id: AccountId,
12    /// Account name
13    pub name: String,
14    /// Formatted balance (e.g. "$1,234.56")
15    #[serde(default, skip_serializing_if = "Option::is_none")]
16    pub balance: Option<String>,
17    /// Currency code (e.g. "USD")
18    #[serde(default, skip_serializing_if = "Option::is_none")]
19    pub currency: Option<iso_currency::Currency>,
20    /// Account classification (e.g. "asset", "liability")
21    #[serde(default, skip_serializing_if = "Option::is_none")]
22    pub classification: Option<String>,
23    /// Accountable type (e.g. "depository", "investment", "credit_card")
24    pub account_type: String,
25}
26
27/// Category information (basic version for transaction references)
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29#[cfg_attr(feature = "strict", serde(deny_unknown_fields))]
30pub struct Category {
31    /// Unique identifier
32    pub id: CategoryId,
33    /// Category name
34    pub name: String,
35    /// Color for UI display (hex code)
36    pub color: String,
37    /// Icon identifier
38    pub icon: String,
39    /// Legacy classification ("income" / "expense"). Optional — older Sure
40    /// deployments still render it on transaction.category; newer ones omit
41    /// it. See [`crate::models::category::CategoryDetail::classification`].
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub classification: Option<String>,
44}
45
46/// Merchant information
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48#[cfg_attr(feature = "strict", serde(deny_unknown_fields))]
49pub struct Merchant {
50    /// Unique identifier
51    pub id: MerchantId,
52    /// Merchant name
53    pub name: String,
54}
55
56/// Tag information
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58#[cfg_attr(feature = "strict", serde(deny_unknown_fields))]
59pub struct Tag {
60    /// Unique identifier
61    pub id: TagId,
62    /// Tag name
63    pub name: String,
64    /// Color for UI display (hex code)
65    pub color: String,
66}
67
68/// Transfer information (for money transfers between accounts)
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70#[cfg_attr(feature = "strict", serde(deny_unknown_fields))]
71pub struct Transfer {
72    /// Unique identifier
73    pub id: TransactionId,
74    /// Transfer amount
75    // #[serde(with = "rust_decimal::serde::arbitrary_precision")]
76    pub amount: String,
77    /// Currency code (e.g., "USD", "EUR")
78    pub currency: iso_currency::Currency,
79    /// The other account involved in the transfer
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub other_account: Option<Account>,
82}
83
84/// Transaction nature/type
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
86#[serde(rename_all = "lowercase")]
87pub enum TransactionNature {
88    /// Income transaction
89    #[serde(alias = "inflow")]
90    Income,
91    /// Expense transaction
92    #[serde(alias = "outflow")]
93    Expense,
94}
95
96impl std::fmt::Display for TransactionNature {
97    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
98        match self {
99            Self::Income => write!(f, "income"),
100            Self::Expense => write!(f, "expense"),
101        }
102    }
103}
104
105/// Error returned when parsing a `TransactionNature` from a string fails.
106#[derive(Debug, Clone, PartialEq, Eq)]
107pub struct ParseTransactionNatureError(String);
108
109impl std::fmt::Display for ParseTransactionNatureError {
110    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
111        write!(f, "Invalid transaction nature: {}", self.0)
112    }
113}
114
115impl std::error::Error for ParseTransactionNatureError {}
116
117impl std::str::FromStr for TransactionNature {
118    type Err = ParseTransactionNatureError;
119
120    fn from_str(s: &str) -> Result<Self, Self::Err> {
121        match s {
122            "income" | "inflow" => Ok(Self::Income),
123            "expense" | "outflow" => Ok(Self::Expense),
124            _ => Err(ParseTransactionNatureError(s.to_string())),
125        }
126    }
127}
128
129impl TryFrom<&str> for TransactionNature {
130    type Error = ParseTransactionNatureError;
131
132    fn try_from(value: &str) -> Result<Self, Self::Error> {
133        value.parse()
134    }
135}
136
137impl TryFrom<String> for TransactionNature {
138    type Error = ParseTransactionNatureError;
139
140    fn try_from(value: String) -> Result<Self, Self::Error> {
141        value.parse()
142    }
143}
144
145/// Transaction filter type
146#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
147#[serde(rename_all = "lowercase")]
148pub enum TransactionType {
149    /// Income transactions
150    Income,
151    /// Expense transactions
152    Expense,
153}
154
155impl std::fmt::Display for TransactionType {
156    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
157        let s = match self {
158            Self::Income => "income",
159            Self::Expense => "expense",
160        };
161        write!(f, "{}", s)
162    }
163}
164
165/// Error returned when parsing a `TransactionType` from a string fails.
166#[derive(Debug, Clone, PartialEq, Eq)]
167pub struct ParseTransactionTypeError(String);
168
169impl std::fmt::Display for ParseTransactionTypeError {
170    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
171        write!(f, "Invalid transaction type: {}", self.0)
172    }
173}
174
175impl std::error::Error for ParseTransactionTypeError {}
176
177impl std::str::FromStr for TransactionType {
178    type Err = ParseTransactionTypeError;
179
180    fn from_str(s: &str) -> Result<Self, Self::Err> {
181        match s {
182            "income" => Ok(Self::Income),
183            "expense" => Ok(Self::Expense),
184            _ => Err(ParseTransactionTypeError(s.to_string())),
185        }
186    }
187}
188
189impl TryFrom<&str> for TransactionType {
190    type Error = ParseTransactionTypeError;
191
192    fn try_from(value: &str) -> Result<Self, Self::Error> {
193        value.parse()
194    }
195}
196
197impl TryFrom<String> for TransactionType {
198    type Error = ParseTransactionTypeError;
199
200    fn try_from(value: String) -> Result<Self, Self::Error> {
201        value.parse()
202    }
203}
204
205/// Complete transaction information
206#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
207#[cfg_attr(feature = "strict", serde(deny_unknown_fields))]
208pub struct Transaction {
209    /// Unique identifier
210    pub id: TransactionId,
211    /// Transaction date
212    #[serde(with = "crate::serde::naive_date")]
213    pub date: DateTime<Utc>,
214    /// Transaction amount, formatted by the server (e.g. "NZ$25.00").
215    pub amount: String,
216    /// Absolute transaction amount in the currency's minor unit (always positive).
217    pub amount_cents: i64,
218    /// Signed transaction amount in the currency's minor unit. Positive for income,
219    /// negative for expenses.
220    pub signed_amount_cents: i64,
221    /// Currency code (e.g., "USD", "EUR")
222    pub currency: iso_currency::Currency,
223    /// Transaction name/description
224    pub name: String,
225    /// Additional notes
226    #[serde(default, skip_serializing_if = "Option::is_none")]
227    pub notes: Option<String>,
228    /// Classification (income/expense)
229    pub classification: String,
230    /// Associated account
231    pub account: Account,
232    /// Associated category
233    #[serde(default, skip_serializing_if = "Option::is_none")]
234    pub category: Option<Category>,
235    /// Associated merchant
236    #[serde(default, skip_serializing_if = "Option::is_none")]
237    pub merchant: Option<Merchant>,
238    /// Associated tags
239    pub tags: Vec<Tag>,
240    /// Associated transfer (if this is a transfer)
241    #[serde(default, skip_serializing_if = "Option::is_none")]
242    pub transfer: Option<Transfer>,
243    /// Creation timestamp
244    pub created_at: DateTime<Utc>,
245    /// Last update timestamp
246    pub updated_at: DateTime<Utc>,
247}
248
249/// Collection of transactions
250#[derive(Debug, Clone, Serialize, Deserialize)]
251#[cfg_attr(feature = "strict", serde(deny_unknown_fields))]
252pub struct TransactionCollection {
253    /// List of transactions
254    pub transactions: Vec<Transaction>,
255}
256
257/// Request body for creating a transaction
258#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
259#[cfg_attr(feature = "strict", serde(deny_unknown_fields))]
260pub(crate) struct CreateTransactionRequest {
261    /// Transaction data
262    pub transaction: CreateTransactionData,
263}
264
265/// Transaction data for creation
266#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
267#[cfg_attr(feature = "strict", serde(deny_unknown_fields))]
268pub(crate) struct CreateTransactionData {
269    /// Account ID (required)
270    pub account_id: AccountId,
271    /// Transaction date (required)
272    pub date: DateTime<Utc>,
273    /// Transaction amount (required)
274    pub amount: Decimal,
275    /// Transaction name/description (required)
276    pub name: String,
277    /// Additional notes
278    #[serde(default, skip_serializing_if = "Option::is_none")]
279    pub notes: Option<String>,
280    /// Currency code
281    #[serde(default, skip_serializing_if = "Option::is_none")]
282    pub currency: Option<iso_currency::Currency>,
283    /// Category ID
284    #[serde(default, skip_serializing_if = "Option::is_none")]
285    pub category_id: Option<CategoryId>,
286    /// Merchant ID
287    #[serde(default, skip_serializing_if = "Option::is_none")]
288    pub merchant_id: Option<MerchantId>,
289    /// Transaction nature (determines sign)
290    #[serde(default, skip_serializing_if = "Option::is_none")]
291    pub nature: Option<TransactionNature>,
292    /// Tag IDs
293    #[serde(default, skip_serializing_if = "Option::is_none")]
294    pub tag_ids: Option<Vec<TagId>>,
295}
296
297/// Request body for updating a transaction
298#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
299#[cfg_attr(feature = "strict", serde(deny_unknown_fields))]
300pub(crate) struct UpdateTransactionRequest {
301    /// Transaction data
302    pub transaction: UpdateTransactionData,
303}
304
305/// Transaction data for updates
306#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
307#[cfg_attr(feature = "strict", serde(deny_unknown_fields))]
308pub(crate) struct UpdateTransactionData {
309    /// Transaction date
310    #[serde(default, skip_serializing_if = "Option::is_none")]
311    pub date: Option<DateTime<Utc>>,
312    /// Transaction amount
313    #[serde(default, skip_serializing_if = "Option::is_none")]
314    pub amount: Option<Decimal>,
315    /// Transaction name/description
316    #[serde(default, skip_serializing_if = "Option::is_none")]
317    pub name: Option<String>,
318    /// Additional notes
319    #[serde(default, skip_serializing_if = "Option::is_none")]
320    pub notes: Option<String>,
321    /// Currency code
322    #[serde(default, skip_serializing_if = "Option::is_none")]
323    pub currency: Option<iso_currency::Currency>,
324    /// Category ID
325    #[serde(default, skip_serializing_if = "Option::is_none")]
326    pub category_id: Option<CategoryId>,
327    /// Merchant ID
328    #[serde(default, skip_serializing_if = "Option::is_none")]
329    pub merchant_id: Option<MerchantId>,
330    /// Transaction nature
331    #[serde(default, skip_serializing_if = "Option::is_none")]
332    pub nature: Option<TransactionNature>,
333    /// Tag IDs
334    #[serde(default, skip_serializing_if = "Option::is_none")]
335    pub tag_ids: Option<Vec<TagId>>,
336}