1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
use std::str::FromStr;
use crate::prelude::*;
#[macro_export]
macro_rules! define_item_struct {
($vis:vis, $name:ident, $quantity_ty:ty) => {
#[derive(
Clone,
Debug,
Display,
PartialEq,
Eq,
Hash,
Serialize,
Deserialize,
Getters,
Setters,
Builder,
)]
#[display(
"{}: {}{} #{} @{}",
name,
unit_price,
currency,
quantity,
transaction_date
)]
$vis struct $name {
/// The short name of the expense, e.g. `"Coffee"`
#[getset(get = "pub")]
name: String,
/// The cost per item
#[getset(get = "pub")]
unit_price: UnitPrice,
/// The currency of the expense, e.g. `"EUR"`, this
/// is the currency in which the expense was paid,
/// and not necessarily the currency of the invoice.
#[getset(get = "pub")]
currency: Currency,
/// The quantity of the expense, e.g. `2.0` for two items
#[getset(get = "pub", set)]
quantity: $quantity_ty,
/// The date of the expense, e.g. `2025-05-31`
#[getset(get = "pub")]
transaction_date: Date,
}
};
}
define_item_struct!(pub, Item, Quantity);
impl HasSample for Item {
fn sample() -> Self {
Self::sample_expense_coffee()
}
fn sample_other() -> Self {
Self::sample_consulting_service()
}
}
impl Item {
pub fn sample_expense_breakfast() -> Self {
Self::builder()
.name("Breakfast".into())
.transaction_date(
Date::builder()
.year(2025.into())
.month(Month::May)
.day(Day::try_from(20).unwrap())
.build(),
)
.quantity(dec!(1.0).into())
.unit_price(dec!(145.0).into())
.currency(Currency::SEK)
.build()
}
pub fn sample_expense_coffee() -> Self {
Self::builder()
.name("Coffee".into())
.transaction_date(Date::sample())
.quantity(dec!(2.0).into())
.unit_price(dec!(4.0).into())
.currency(Currency::GBP)
.build()
}
pub fn sample_consulting_service() -> Self {
Self::builder()
.name("Agreed Consulting Fees".into())
.transaction_date(Date::sample())
.quantity(dec!(22.0).into())
.unit_price(dec!(500.0).into())
.currency(Currency::EUR)
.build()
}
}
impl Item {
/// Converts the item into a new item with the total cost calculated
/// in the target currency, using the provided exchange rates.
///
/// # Examples
/// ```
/// extern crate klirr_core;
/// use klirr_core::prelude::*;
/// let item = Item::from_str("Coffee,2.5, EUR,3.0, 2025-05-31").expect("Failed to parse Item");
/// let exchange_rates = ExchangeRates::builder()
/// .target_currency(Currency::USD)
/// .rates(ExchangeRatesMap::from([
/// (Currency::EUR, UnitPrice::from(dec!(1.2))),
/// (Currency::GBP, UnitPrice::from(dec!(1.5))),
/// ]))
/// .build();
/// let converted_item = item.total_cost_in_target_currency(&exchange_rates).expect("Failed to convert item");
/// assert_eq!(converted_item.name(), "Coffee");
/// assert_eq!(**converted_item.unit_price(), dec!(3.0)); // EUR to USD conversion
/// assert_eq!(converted_item.currency(), &Currency::USD);
/// ```
pub fn total_cost_in_target_currency(
self,
exchange_rates: &ExchangeRates,
) -> Result<ItemConvertedIntoTargetCurrency> {
let converted_rates = self.with_exchange_rates(exchange_rates)?;
Ok(converted_rates.with_total_cost())
}
/// Maps an `Item` into an `ItemConvertedIntoTargetCurrency` with the total cost
/// calculated in the source currency.
///
/// # Examples
/// ```
/// extern crate klirr_core;
/// use klirr_core::prelude::*;
/// let item = Item::from_str("Coffee,2.5, EUR,3.0, 2025-05-31").expect("Failed to parse Item");
/// let converted_item = item.with_total_cost();
/// assert_eq!(**converted_item.total_cost(), dec!(7.50)); // 2.5 * 3.0
/// ```
pub fn with_total_cost(self) -> ItemConvertedIntoTargetCurrency {
let cost = Cost::from(**self.quantity() * **self.unit_price());
ItemConvertedIntoTargetCurrency::builder()
.in_source_currency(self.clone())
.total_cost(cost)
.build()
}
/// Converts the item into a new item with the unit price converted to the target currency
/// using the provided exchange rates.
fn with_exchange_rates(self, exchange_rates: &ExchangeRates) -> Result<Self> {
let converted_unit_price = exchange_rates.convert(self.unit_price, self.currency)?;
Ok(Self::builder()
.transaction_date(self.transaction_date)
.name(self.name)
.unit_price(converted_unit_price)
.quantity(self.quantity)
.currency(*exchange_rates.target_currency())
.build())
}
}
impl FromStr for Item {
type Err = crate::prelude::Error;
/// Parses a string in the format: "name, unit_price, currency, quantity, transaction_date", or
/// without spaces after commas, even mixed, e.g. "Coffee, 2.5,EUR, 3.0,2025-05-31".
fn from_str(s: &str) -> Result<Self, Self::Err> {
let parts: Vec<&str> = s.split(',').map(str::trim).collect();
if parts.len() != 5 {
return Err(Error::InvalidExpenseItem {
invalid_string: s.to_string(),
reason: "Expected 5 comma-separated values, on format: \"Coffee, 2.5, EUR, 3.0, 2025-05-31\"".to_string(),
});
}
let name = parts[0].to_string();
let unit_price: UnitPrice = parts[1]
.parse::<Decimal>()
.map_err(|e| Error::InvalidExpenseItem {
invalid_string: s.to_string(),
reason: format!("Failed to parse unit_price: {e}"),
})?
.into();
let currency = Currency::from_str(parts[2]).map_err(|e| Error::InvalidExpenseItem {
invalid_string: s.to_string(),
reason: format!("Failed to parse currency: {e}"),
})?;
let quantity: Quantity = parts[3]
.parse::<Decimal>()
.map_err(|e| Error::InvalidExpenseItem {
invalid_string: s.to_string(),
reason: format!("Failed to parse quantity: {e}"),
})?
.into();
if quantity < Quantity::ZERO {
return Err(Error::InvalidExpenseItem {
invalid_string: s.to_string(),
reason: "Quantity cannot be negative".to_string(),
});
}
let transaction_date = Date::from_str(parts[4]).map_err(|e| Error::InvalidExpenseItem {
invalid_string: s.to_string(),
reason: format!("Failed to parse transaction_date: {e}"),
})?;
Ok(Item::builder()
.name(name)
.unit_price(unit_price)
.currency(currency)
.quantity(quantity)
.transaction_date(transaction_date)
.build())
}
}
#[cfg(test)]
mod tests {
use super::*;
use test_log::test;
type Sut = Item;
#[test]
fn equality() {
assert_eq!(Sut::sample(), Sut::sample());
assert_eq!(Sut::sample_other(), Sut::sample_other());
}
#[test]
fn inequality() {
assert_ne!(Sut::sample(), Sut::sample_other());
}
#[test]
fn test_from_str() {
// N.B. sometimes space after comma, sometimes not.
let sut = Sut::from_str("Coffee,2.5, EUR,3.0, 2025-05-31").expect("Failed to parse Item");
assert_eq!(sut.name(), "Coffee");
assert_eq!(**sut.unit_price(), dec!(2.5));
assert_eq!(sut.currency(), &Currency::EUR);
assert_eq!(**sut.quantity(), dec!(3.0));
assert_eq!(
sut.transaction_date(),
&Date::from_str("2025-05-31").unwrap()
);
}
#[test]
fn from_str_invalid() {
let invalid_strings = [
"Coffee,2.5, EUR,3.0", // Missing transaction_date
"Coffee,2.5, EUR,3.0, invalid_date", // Invalid transaction_date
"Coffee,2.5, EUR,3.0, 2025-05-31, extra", // Too many parts
"Coffee,invalid_price, EUR,3.0, 2025-05-31", // Invalid unit_price
"Coffee,2.5, invalid_currency,3.0, 2025-05-31", // Invalid currency
"Coffee,2.5, EUR,-3.0, 2025-05-31", // Negative quantity
"Coffee,2.5, EUR,a, 2025-05-31", // Negative quantity
];
for &s in &invalid_strings {
assert!(Sut::from_str(s).is_err(), "Expected error for: {}", s);
}
}
}