1use anyhow::Result;
28use async_trait::async_trait;
29use std::{
30 collections::HashMap,
31 sync::{Arc, Mutex},
32 time::{SystemTime, UNIX_EPOCH},
33};
34use tokio::sync::mpsc;
35
36use wactorz_core::{Actor, ActorConfig, ActorMetrics, ActorState, EventPublisher, Message};
37
38#[derive(Clone)]
41struct Expense {
42 amount: f64,
43 category: String,
44 #[allow(dead_code)] note: String,
46 ts_ms: u64,
47}
48
49pub struct WifAgent {
52 config: ActorConfig,
53 state: ActorState,
54 metrics: Arc<ActorMetrics>,
55 mailbox_tx: mpsc::Sender<Message>,
56 mailbox_rx: Option<mpsc::Receiver<Message>>,
57 publisher: Option<EventPublisher>,
58 expenses: Arc<Mutex<Vec<Expense>>>,
59 budgets: Arc<Mutex<HashMap<String, f64>>>,
60}
61
62impl WifAgent {
63 pub fn new(config: ActorConfig) -> Self {
64 let (tx, rx) = mpsc::channel(config.mailbox_capacity);
65 Self {
66 config,
67 state: ActorState::Initializing,
68 metrics: Arc::new(ActorMetrics::new()),
69 mailbox_tx: tx,
70 mailbox_rx: Some(rx),
71 publisher: None,
72 expenses: Arc::new(Mutex::new(Vec::new())),
73 budgets: Arc::new(Mutex::new(HashMap::new())),
74 }
75 }
76
77 pub fn with_publisher(mut self, p: EventPublisher) -> Self {
78 self.publisher = Some(p);
79 self
80 }
81
82 fn now_ms() -> u64 {
83 SystemTime::now()
84 .duration_since(UNIX_EPOCH)
85 .unwrap_or_default()
86 .as_millis() as u64
87 }
88
89 fn reply(&self, content: &str) {
90 if let Some(pub_) = &self.publisher {
91 pub_.publish(
92 wactorz_mqtt::topics::chat(&self.config.id),
93 &serde_json::json!({
94 "from": self.config.name,
95 "to": "user",
96 "content": content,
97 "timestampMs": Self::now_ms(),
98 }),
99 );
100 }
101 }
102
103 fn cmd_add(&self, parts: &[&str]) -> String {
106 if parts.is_empty() {
107 return "Usage: `add <amount> [category] [note…]`\n\nExample: `add 25.50 food coffee`"
108 .to_string();
109 }
110
111 let raw = parts[0].trim_start_matches(['$', '€', '£', '¥', '+']);
112 let amount: f64 = match raw.parse() {
113 Ok(v) if v > 0.0 => v,
114 Ok(_) => return "Amount must be positive.".to_string(),
115 Err(_) => return format!("Invalid amount: `{}`", parts[0]),
116 };
117
118 let category = parts.get(1).copied().unwrap_or("misc").to_lowercase();
119 let note = parts.get(2..).map(|p| p.join(" ")).unwrap_or_default();
120
121 let expense = Expense {
122 amount,
123 category: category.clone(),
124 note: note.clone(),
125 ts_ms: Self::now_ms(),
126 };
127 let mut expenses = self.expenses.lock().unwrap();
128 expenses.push(expense);
129
130 let total: f64 = expenses
131 .iter()
132 .filter(|e| e.category == category)
133 .map(|e| e.amount)
134 .sum();
135
136 let budgets = self.budgets.lock().unwrap();
137 let budget_line = if let Some(&budget) = budgets.get(&category) {
138 let pct = (total / budget * 100.0).min(999.0);
139 let remaining = budget - total;
140 let icon = if pct >= 100.0 {
141 "🔴"
142 } else if pct >= 80.0 {
143 "🟡"
144 } else {
145 "🟢"
146 };
147 format!(
148 "\n{icon} **{category}** budget: ${total:.2} / ${budget:.2} ({pct:.0}%) — ${remaining:.2} left"
149 )
150 } else {
151 format!("\n📊 **{category}** running total: ${total:.2}")
152 };
153
154 let note_part = if note.is_empty() {
155 String::new()
156 } else {
157 format!(" _{note}_")
158 };
159 format!("✅ Logged **${amount:.2}** → `{category}`{note_part}{budget_line}")
160 }
161
162 fn cmd_budget(&self, parts: &[&str]) -> String {
163 if parts.len() < 2 {
164 return "Usage: `budget <category> <amount>`\n\nExample: `budget food 300`".to_string();
165 }
166 let category = parts[0].to_lowercase();
167 let raw = parts[1].trim_start_matches(['$', '€', '£', '¥']);
168 let amount: f64 = match raw.parse() {
169 Ok(v) if v >= 0.0 => v,
170 Ok(_) => return "Budget must be ≥ 0.".to_string(),
171 Err(_) => return format!("Invalid amount: `{}`", parts[1]),
172 };
173
174 let mut budgets = self.budgets.lock().unwrap();
175 let verb = if budgets.contains_key(&category) {
176 "Updated"
177 } else {
178 "Set"
179 };
180 budgets.insert(category.clone(), amount);
181
182 let expenses = self.expenses.lock().unwrap();
183 let spent: f64 = expenses
184 .iter()
185 .filter(|e| e.category == category)
186 .map(|e| e.amount)
187 .sum();
188 let pct = if amount > 0.0 {
189 spent / amount * 100.0
190 } else {
191 0.0
192 };
193 let icon = if pct >= 100.0 {
194 "🔴"
195 } else if pct >= 80.0 {
196 "🟡"
197 } else {
198 "🟢"
199 };
200
201 format!(
202 "📋 {verb} budget: **{category}** → **${amount:.2}**\n{icon} Currently at ${spent:.2} ({pct:.0}%)"
203 )
204 }
205
206 fn cmd_summary(&self, period: &str) -> String {
207 let expenses = self.expenses.lock().unwrap();
208 if expenses.is_empty() {
209 return "📭 No expenses recorded yet.\n\nTry: `add 25 food coffee` to get started."
210 .to_string();
211 }
212
213 let now_ms = Self::now_ms();
214 let cutoff_ms: u64 = match period {
215 "today" => now_ms.saturating_sub(86_400_000),
216 "week" => now_ms.saturating_sub(7 * 86_400_000),
217 "month" => now_ms.saturating_sub(30 * 86_400_000),
218 _ => 0,
219 };
220
221 let filtered: Vec<&Expense> = expenses.iter().filter(|e| e.ts_ms >= cutoff_ms).collect();
222 if filtered.is_empty() {
223 return format!("📭 No expenses for `{period}`. Try: `summary all`");
224 }
225
226 let total: f64 = filtered.iter().map(|e| e.amount).sum();
227 let mut by_cat: HashMap<String, f64> = HashMap::new();
228 for e in &filtered {
229 *by_cat.entry(e.category.clone()).or_default() += e.amount;
230 }
231
232 let mut cats: Vec<(String, f64)> = by_cat.into_iter().collect();
233 cats.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
234
235 let budgets = self.budgets.lock().unwrap();
236 let period_label = match period {
237 "today" => "Today",
238 "week" => "This Week",
239 "month" => "This Month",
240 _ => "All Time",
241 };
242
243 let rows: Vec<String> = cats
244 .iter()
245 .map(|(cat, amt)| {
246 let frac = if total > 0.0 { amt / total } else { 0.0 };
247 let bar = Self::mini_bar(frac);
248 let budget_note = budgets
249 .get(cat)
250 .map(|&b| {
251 let pct = amt / b * 100.0;
252 let icon = if pct >= 100.0 {
253 "🔴"
254 } else if pct >= 80.0 {
255 "🟡"
256 } else {
257 "🟢"
258 };
259 format!(" {icon} {pct:.0}% of ${b:.0}")
260 })
261 .unwrap_or_default();
262 format!(" {bar} **{cat}**: ${amt:.2}{budget_note}")
263 })
264 .collect();
265
266 format!(
267 "**💰 Expense Summary — {period_label}**\n\n{}\n\n**Total: ${total:.2}** ({n} transactions)",
268 rows.join("\n"),
269 n = filtered.len(),
270 )
271 }
272
273 fn mini_bar(fraction: f64) -> &'static str {
274 match (fraction * 5.0).round() as usize {
275 0 => "▁",
276 1 => "▂",
277 2 => "▄",
278 3 => "▆",
279 4 => "▇",
280 _ => "█",
281 }
282 }
283
284 fn cmd_balance(&self) -> String {
285 let budgets = self.budgets.lock().unwrap();
286 if budgets.is_empty() {
287 return "📋 No budgets set yet.\n\nTry: `budget food 300` then `add 25 food coffee`"
288 .to_string();
289 }
290
291 let expenses = self.expenses.lock().unwrap();
292 let mut sorted: Vec<String> = budgets.keys().cloned().collect();
293 sorted.sort();
294
295 let mut rows = Vec::new();
296 let mut total_budget = 0.0f64;
297 let mut total_spent = 0.0f64;
298
299 for cat in &sorted {
300 let budget = budgets[cat];
301 let spent: f64 = expenses
302 .iter()
303 .filter(|e| e.category == *cat)
304 .map(|e| e.amount)
305 .sum();
306 let pct = if budget > 0.0 {
307 (spent / budget * 100.0).min(999.9)
308 } else {
309 0.0
310 };
311 let filled = ((pct / 100.0 * 10.0).min(10.0)) as usize;
312 let bar = format!("[{}{}]", "█".repeat(filled), "░".repeat(10 - filled));
313 let icon = if pct >= 100.0 {
314 "🔴"
315 } else if pct >= 80.0 {
316 "🟡"
317 } else {
318 "🟢"
319 };
320 let remaining = budget - spent;
321 let rem_str = if remaining >= 0.0 {
322 format!("${remaining:.2} left")
323 } else {
324 format!("${:.2} over", remaining.abs())
325 };
326 rows.push(format!(
327 "{icon} **{cat}**: {bar} ${spent:.2} / ${budget:.2} ({pct:.0}%) — {rem_str}"
328 ));
329 total_budget += budget;
330 total_spent += spent;
331 }
332
333 let overall_pct = if total_budget > 0.0 {
334 (total_spent / total_budget * 100.0).min(999.9)
335 } else {
336 0.0
337 };
338 let overall_icon = if overall_pct >= 100.0 {
339 "🔴"
340 } else if overall_pct >= 80.0 {
341 "🟡"
342 } else {
343 "🟢"
344 };
345 rows.push(String::new());
346 rows.push(format!(
347 "{overall_icon} **TOTAL**: ${total_spent:.2} / ${total_budget:.2} ({overall_pct:.0}%)"
348 ));
349
350 format!("**📊 Budget Balance**\n\n{}", rows.join("\n"))
351 }
352
353 fn cmd_clear(&self, category: Option<&str>) -> String {
354 let mut expenses = self.expenses.lock().unwrap();
355 match category {
356 None => {
357 let n = expenses.len();
358 expenses.clear();
359 format!("🗑 Cleared all {n} expenses.")
360 }
361 Some(cat) => {
362 let before = expenses.len();
363 expenses.retain(|e| e.category != cat);
364 let removed = before - expenses.len();
365 format!("🗑 Cleared {removed} expenses from `{cat}`.")
366 }
367 }
368 }
369
370 fn cmd_calc(&self, parts: &[&str]) -> String {
371 match parts.first().copied().unwrap_or("") {
372 "compound" | "ci" => {
373 if parts.len() < 4 {
374 return "Usage: `calc compound <principal> <rate%> <years>`\n\nExample: `calc compound 10000 7 20`".to_string();
375 }
376 let p: f64 = parts[1].trim_start_matches('$').parse().unwrap_or(0.0);
377 let r: f64 = parts[2].trim_end_matches('%').parse::<f64>().unwrap_or(0.0) / 100.0;
378 let t: f64 = parts[3].parse().unwrap_or(0.0);
379 if p <= 0.0 || t <= 0.0 {
380 return "Principal and years must be positive.".to_string();
381 }
382 let n = 12.0; let fv = p * (1.0 + r / n).powf(n * t);
384 let int = fv - p;
385 let rate_pct = r * 100.0;
386 let gain_pct = if p > 0.0 { int / p * 100.0 } else { 0.0 };
387 format!(
388 "**📈 Compound Interest (monthly)**\n\nPrincipal : ${p:.2}\nRate : {rate_pct:.2}% p.a.\nTerm : {t} years\n\n→ Future Value : **${fv:.2}**\n→ Interest Earned: **${int:.2}** ({gain_pct:.0}% gain)"
389 )
390 }
391
392 "loan" | "mortgage" => {
393 if parts.len() < 4 {
394 return "Usage: `calc loan <principal> <rate%> <years>`\n\nExample: `calc loan 300000 4.5 30`".to_string();
395 }
396 let p: f64 = parts[1].trim_start_matches('$').parse().unwrap_or(0.0);
397 let r: f64 =
398 parts[2].trim_end_matches('%').parse::<f64>().unwrap_or(0.0) / 100.0 / 12.0;
399 let n: f64 = parts[3].parse::<f64>().unwrap_or(0.0) * 12.0;
400 if p <= 0.0 || n <= 0.0 {
401 return "Principal and years must be positive.".to_string();
402 }
403 let (monthly, total, interest) = if r == 0.0 {
404 let m = p / n;
405 (m, p, 0.0)
406 } else {
407 let m = p * r * (1.0 + r).powf(n) / ((1.0 + r).powf(n) - 1.0);
408 (m, m * n, m * n - p)
409 };
410 let rate_pct: f64 = parts[2].trim_end_matches('%').parse().unwrap_or(0.0);
411 format!(
412 "**🏠 Loan / Mortgage Calculator**\n\nPrincipal : ${p:.2}\nRate : {rate_pct:.2}% p.a.\nTerm : {} years\n\n→ Monthly Payment : **${monthly:.2}**\n→ Total Repaid : **${total:.2}**\n→ Total Interest : **${interest:.2}**",
413 parts[3],
414 )
415 }
416
417 "roi" => {
418 if parts.len() < 3 {
419 return "Usage: `calc roi <initial> <final>`\n\nExample: `calc roi 5000 7500`"
420 .to_string();
421 }
422 let initial: f64 = parts[1].trim_start_matches('$').parse().unwrap_or(0.0);
423 let final_val: f64 = parts[2].trim_start_matches('$').parse().unwrap_or(0.0);
424 if initial == 0.0 {
425 return "Initial value cannot be zero.".to_string();
426 }
427 let roi = (final_val - initial) / initial * 100.0;
428 let gain = final_val - initial;
429 let icon = if gain >= 0.0 { "📈" } else { "📉" };
430 format!(
431 "{icon} **Return on Investment**\n\nInitial : ${initial:.2}\nFinal : ${final_val:.2}\nGain : ${gain:+.2}\n\n→ ROI: **{roi:+.2}%**"
432 )
433 }
434
435 "tax" => {
436 if parts.len() < 2 {
437 return "Usage: `calc tax <income> [rate%]`\n\nExample: `calc tax 75000 25`"
438 .to_string();
439 }
440 let income: f64 = parts[1].trim_start_matches('$').parse().unwrap_or(0.0);
441 let rate: f64 = parts
442 .get(2)
443 .and_then(|s| s.trim_end_matches('%').parse().ok())
444 .unwrap_or(25.0);
445 let tax = income * rate / 100.0;
446 let net = income - tax;
447 format!(
448 "**💸 Tax Estimate**\n\nGross Income : ${income:.2}\nTax Rate : {rate:.1}%\n\n→ Tax : **${tax:.2}**\n→ Net Income : **${net:.2}**\n\n_Note: simplified estimate — consult a tax professional._"
449 )
450 }
451
452 _ => "**calc** subcommands:\n\n\
453 ```\n\
454 calc compound <principal> <rate%> <years> — compound interest\n\
455 calc loan <principal> <rate%> <years> — loan / mortgage\n\
456 calc roi <initial> <final> — return on investment\n\
457 calc tax <income> [rate%] — tax estimate (default 25%)\n\
458 ```"
459 .to_string(),
460 }
461 }
462
463 fn cmd_tips(&self, topic: &str) -> String {
464 match topic {
465 "saving" | "save" => "**💡 Saving Tips**\n\n\
466 1. **50/30/20 rule** — 50% needs · 30% wants · 20% savings\n\
467 2. **Pay yourself first** — automate a transfer on payday\n\
468 3. **Emergency fund** — target 3–6 months of expenses\n\
469 4. **Cut subscriptions** — review monthly recurring charges\n\
470 5. **Track everything** — use `add <amount> <category>` to log expenses\n\n\
471 _Try: `budget food 300` then `add 25 food coffee` to start tracking._"
472 .to_string(),
473 "investing" | "invest" => "**📈 Investing Tips**\n\n\
474 1. **Start early** — compound interest is exponential; time matters most\n\
475 2. **Diversify** — spread across asset classes, geographies\n\
476 3. **Low-cost index funds** — outperform most active funds long-term\n\
477 4. **Dollar-cost average** — invest fixed amounts on a schedule\n\
478 5. **Don't time the market** — time *in* the market beats timing it\n\n\
479 _Try: `calc compound 10000 8 30` to see long-term growth._"
480 .to_string(),
481 "debt" => "**⚡ Debt Elimination Tips**\n\n\
482 1. **Avalanche method** — pay highest-interest debt first (saves most money)\n\
483 2. **Snowball method** — pay smallest balance first (psychological wins)\n\
484 3. **Never miss minimums** — late fees and credit damage compound fast\n\
485 4. **Refinance wisely** — lower rates can cut years off repayment\n\
486 5. **No new debt** — stop accumulating while paying off existing debt\n\n\
487 _Try: `calc loan 20000 18.9 5` to see credit-card debt cost._"
488 .to_string(),
489 "budget" => "**📋 Budgeting Tips**\n\n\
490 1. **Zero-based budget** — give every dollar a job\n\
491 2. **Set category limits** — use `budget <category> <amount>`\n\
492 3. **Check balance weekly** — use `balance` to see spend vs budget\n\
493 4. **Review monthly** — adjust budgets to reflect actual life\n\
494 5. **Include fun money** — rigid budgets fail; build in discretionary\n\n\
495 _Use `summary month` for a monthly breakdown._"
496 .to_string(),
497 _ => "**WIF Financial Tips** — pick a topic:\n\n\
498 ```\n\
499 tips saving — spending reduction strategies\n\
500 tips investing — growing wealth over time\n\
501 tips debt — paying off debt efficiently\n\
502 tips budget — budgeting best practices\n\
503 ```"
504 .to_string(),
505 }
506 }
507
508 fn dispatch(&self, text: &str) -> String {
509 let arg = text.strip_prefix("@wif-agent").unwrap_or(text).trim();
510
511 let parts: Vec<&str> = arg.split_whitespace().collect();
512 let cmd = parts.first().copied().unwrap_or("help");
513
514 match cmd {
515 "add" => self.cmd_add(&parts[1..]),
516 "budget" => self.cmd_budget(&parts[1..]),
517 "summary" => self.cmd_summary(parts.get(1).copied().unwrap_or("all")),
518 "balance" => self.cmd_balance(),
519 "clear" => self.cmd_clear(parts.get(1).copied()),
520 "calc" => self.cmd_calc(&parts[1..]),
521 "tips" => self.cmd_tips(parts.get(1).copied().unwrap_or("")),
522 "help" | "" => "**WIF — Finance Expert** 💹\n\
523 _Waldiez Intelligence Finance_\n\n\
524 ```\n\
525 add <amount> [category] [note] log an expense\n\
526 budget <category> <amount> set budget limit\n\
527 summary [today|week|month|all] spending report\n\
528 balance budget vs actuals\n\
529 clear [category] reset expenses\n\
530 calc compound <p> <rate%> <years> compound interest\n\
531 calc loan <p> <rate%> <years> loan / mortgage\n\
532 calc roi <initial> <final> return on invest\n\
533 calc tax <income> [rate%] tax estimate\n\
534 tips [saving|investing|debt|budget] financial advice\n\
535 help this message\n\
536 ```"
537 .to_string(),
538 _ => format!("Unknown command: `{cmd}`. Type `help` for the full command list."),
539 }
540 }
541}
542
543#[async_trait]
546impl Actor for WifAgent {
547 fn id(&self) -> String {
548 self.config.id.clone()
549 }
550 fn name(&self) -> &str {
551 &self.config.name
552 }
553 fn state(&self) -> ActorState {
554 self.state.clone()
555 }
556 fn metrics(&self) -> Arc<ActorMetrics> {
557 Arc::clone(&self.metrics)
558 }
559 fn mailbox(&self) -> mpsc::Sender<Message> {
560 self.mailbox_tx.clone()
561 }
562 fn is_protected(&self) -> bool {
563 self.config.protected
564 }
565
566 async fn on_start(&mut self) -> Result<()> {
567 self.state = ActorState::Running;
568 if let Some(pub_) = &self.publisher {
569 pub_.publish(
570 wactorz_mqtt::topics::spawn(&self.config.id),
571 &serde_json::json!({
572 "agentId": self.config.id,
573 "agentName": self.config.name,
574 "agentType": "financier",
575 "timestampMs": Self::now_ms(),
576 }),
577 );
578 }
579 Ok(())
580 }
581
582 async fn handle_message(&mut self, message: Message) -> Result<()> {
583 use wactorz_core::message::MessageType;
584
585 let content = match &message.payload {
586 MessageType::Text { content } => content.trim().to_string(),
587 MessageType::Task { description, .. } => description.trim().to_string(),
588 _ => return Ok(()),
589 };
590
591 let reply = self.dispatch(&content);
592 self.reply(&reply);
593 Ok(())
594 }
595
596 async fn on_heartbeat(&mut self) -> Result<()> {
597 if let Some(pub_) = &self.publisher {
598 pub_.publish(
599 wactorz_mqtt::topics::heartbeat(&self.config.id),
600 &serde_json::json!({
601 "agentId": self.config.id,
602 "agentName": self.config.name,
603 "state": self.state,
604 "timestampMs": Self::now_ms(),
605 }),
606 );
607 }
608 Ok(())
609 }
610
611 async fn run(&mut self) -> Result<()> {
612 self.on_start().await?;
613
614 let mut rx = self
615 .mailbox_rx
616 .take()
617 .ok_or_else(|| anyhow::anyhow!("WifAgent already running"))?;
618
619 let mut hb = tokio::time::interval(std::time::Duration::from_secs(
620 self.config.heartbeat_interval_secs,
621 ));
622 hb.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
623
624 loop {
625 tokio::select! {
626 biased;
627 msg = rx.recv() => match msg {
628 None => break,
629 Some(m) => {
630 self.metrics.record_received();
631 if let wactorz_core::message::MessageType::Command {
632 command: wactorz_core::message::ActorCommand::Stop,
633 } = &m.payload { break; }
634 match self.handle_message(m).await {
635 Ok(_) => self.metrics.record_processed(),
636 Err(e) => {
637 tracing::error!("[{}] {e}", self.config.name);
638 self.metrics.record_failed();
639 }
640 }
641 }
642 },
643 _ = hb.tick() => {
644 self.metrics.record_heartbeat();
645 if let Err(e) = self.on_heartbeat().await {
646 tracing::error!("[{}] heartbeat: {e}", self.config.name);
647 }
648 }
649 }
650 }
651
652 self.state = ActorState::Stopped;
653 self.on_stop().await
654 }
655}