diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f98bf00..8c77604 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -177,6 +177,15 @@ jobs: needs: [version, build-linux, build-windows] runs-on: ubuntu-latest steps: + # Checkout first: it would wipe the downloaded artifacts otherwise. + - uses: actions/checkout@v4 + with: + ref: v${{ needs.version.outputs.version }} + fetch-depth: 0 + + - name: Write release notes from the commits since the last release + run: ./scripts/release-notes.sh "v${{ needs.version.outputs.version }}" > release-notes.md + - uses: actions/download-artifact@v4 with: path: artifacts @@ -186,5 +195,5 @@ jobs: uses: softprops/action-gh-release@v2 with: tag_name: v${{ needs.version.outputs.version }} - generate_release_notes: true + body_path: release-notes.md files: artifacts/* diff --git a/ROADMAP.md b/ROADMAP.md index 81f6106..4955afe 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -10,7 +10,7 @@ Features that build directly on what exists today. - Set monthly or custom-period budgets for expense categories. - Show progress bars and warnings on Overview and Categories pages. -- [ ] **Ability to mark subscriptions as active/inactive** +- [x] **Ability to mark subscriptions as active/inactive** - Show a green circle to mark if a subscription is active - When I edit a subscription show a dropdown to mark a subscription as active or inactive @@ -20,14 +20,15 @@ Features that build directly on what exists today. - [ ] **Transaction search** - Search Recent Transactions by description, merchant, category, or amount. + - Done: description, merchant, type, category and account. Still to do: amount. -- [ ] **Auto calculate charges for Ecocash** +- [x] **Auto calculate charges for Ecocash** - For payments and tranfers done using Ecocash specify amount spend and left amount then auto calculate charges -- [ ] **Protect account deletion** +- [x] **Protect account deletion** - If a user press delete account prompt them to confirm before deletion -- [ ] **Correct account amounts** +- [x] **Correct account amounts** - Add an Edit button besides the delete account which should allow editing account amounts ## Next @@ -51,7 +52,7 @@ Features that add more insight and automation. Bigger features and platform-level improvements. -- [ ] **Export data** +- [x] **Export data** - Export transactions, accounts, categories, and subscriptions to CSV/JSON. - [ ] **Backup / restore** diff --git a/scripts/release-notes.sh b/scripts/release-notes.sh new file mode 100755 index 0000000..7fe0156 --- /dev/null +++ b/scripts/release-notes.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# Print release notes for TAG: one line per commit since the previous tag, +# leaving out merges, version bumps and roadmap bookkeeping. +set -euo pipefail + +TAG="${1:?Usage: $0 }" +PREV=$(git describe --tags --abbrev=0 --match 'v*' "${TAG}^" 2>/dev/null || true) + +echo "## What's Changed" +echo +git log --no-merges --reverse --format='- %s' ${PREV:+$PREV..}"$TAG" \ + | grep -v -i -e '\[skip ci\]' -e '^- chore: release v' -e 'roadmap' || true + +if [ -n "$PREV" ]; then + REPO_URL=$(git remote get-url origin 2>/dev/null | sed -E 's#^git@github.com:#https://github.com/#; s#\.git$##') + echo + echo "**Full Changelog**: ${REPO_URL}/compare/${PREV}...${TAG}" +fi diff --git a/src/commands/mod.rs b/src/commands/mod.rs index a7a7632..b24f9c0 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -408,6 +408,19 @@ pub fn create_transaction( }) } +/// Mobile money accounts (Ecocash and friends) take a fee on payments and +/// transfers that the confirmation SMS only reveals through the balance left. +pub fn charges_apply(account: &Account) -> bool { + account.account_type == "mobile_money" || account.name.to_lowercase().contains("ecocash") +} + +/// The fee taken when `spent` leaves `left` out of `balance`, or `None` when +/// `left` is more than the payment could have left behind. +pub fn charges_from_balance_left(balance: i64, spent: i64, left: i64) -> Option { + let charges = balance - spent - left; + (charges >= 0).then_some(charges) +} + pub fn update_transaction( state: &DbState, request: UpdateTransactionRequest, @@ -417,30 +430,30 @@ pub fn update_transaction( // Get old transaction for balance adjustment let old = get_transaction_by_id(&conn, &request.id)?; - if let Some(amount) = request.amount { - if amount != old.amount || request.transaction_type.as_deref() != Some(&old.transaction_type) { - // Reverse old balance change - let old_change = if old.transaction_type == "income" { - -old.amount - } else { - old.amount - }; - conn.execute( - "UPDATE accounts SET balance = balance + ?1, updated_at = datetime('now') WHERE id = ?2", - params![old_change, old.account_id], - ) - .map_err(|e| e.to_string())?; - - // Apply new balance change - let new_type = request.transaction_type.as_deref().unwrap_or(old.transaction_type.as_str()); - let new_change = if new_type == "income" { amount } else { -amount }; - let account_id = request.account_id.as_deref().unwrap_or(&old.account_id); - conn.execute( - "UPDATE accounts SET balance = balance + ?1, updated_at = datetime('now') WHERE id = ?2", - params![new_change, account_id], - ) - .map_err(|e| e.to_string())?; - } + // Moving to another account, or changing the amount or type, shifts balances. + let amount = request.amount.unwrap_or(old.amount); + let new_type = request.transaction_type.as_deref().unwrap_or(old.transaction_type.as_str()); + let account_id = request.account_id.as_deref().unwrap_or(&old.account_id); + if amount != old.amount || new_type != old.transaction_type || account_id != old.account_id { + // Reverse old balance change + let old_change = if old.transaction_type == "income" { + -old.amount + } else { + old.amount + }; + conn.execute( + "UPDATE accounts SET balance = balance + ?1, updated_at = datetime('now') WHERE id = ?2", + params![old_change, old.account_id], + ) + .map_err(|e| e.to_string())?; + + // Apply new balance change + let new_change = if new_type == "income" { amount } else { -amount }; + conn.execute( + "UPDATE accounts SET balance = balance + ?1, updated_at = datetime('now') WHERE id = ?2", + params![new_change, account_id], + ) + .map_err(|e| e.to_string())?; } // Update fields @@ -1536,6 +1549,26 @@ mod tests { DbState(Mutex::new(conn)) } + #[test] + fn charges_are_what_the_balance_left_does_not_explain() { + assert_eq!(charges_from_balance_left(100_00, 20_00, 79_40), Some(60)); + assert_eq!(charges_from_balance_left(100_00, 20_00, 80_00), Some(0)); + assert_eq!(charges_from_balance_left(100_00, 20_00, 81_00), None); + } + + #[test] + fn charges_apply_to_mobile_money_and_ecocash_accounts() { + let mut account = get_accounts(&seeded()).unwrap().remove(0); + account.account_type = "bank".into(); + account.name = "Checking".into(); + assert!(!charges_apply(&account)); + account.name = "My EcoCash".into(); + assert!(charges_apply(&account)); + account.name = "Wallet".into(); + account.account_type = "mobile_money".into(); + assert!(charges_apply(&account)); + } + fn csv(name: &str, body: &str) -> String { let dir = std::env::temp_dir().join(format!("chelete-cmd-{name}-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); @@ -1660,6 +1693,35 @@ mod tests { assert_eq!(balance(&db, &id), after_create - 600); } + #[test] + fn moving_a_transaction_to_another_account_moves_its_amount() { + let db = seeded(); + let accounts = get_accounts(&db).unwrap(); + let (a, b) = (accounts[0].id.clone(), accounts[1].id.clone()); + let t = create_transaction(&db, new_txn(&a, "expense", 1000)).unwrap(); + let (sa, sb) = (balance(&db, &a), balance(&db, &b)); + // The edit form sends every field, so the amount and type come back unchanged. + update_transaction( + &db, + UpdateTransactionRequest { + id: t.id.clone(), + account_id: Some(b.clone()), + category_id: None, + transaction_type: Some("expense".into()), + amount: Some(1000), + currency: None, + description: None, + merchant: None, + notes: None, + transaction_date: None, + }, + ) + .unwrap(); + assert_eq!((balance(&db, &a), balance(&db, &b)), (sa + 1000, sb - 1000)); + delete_transactions(&db, vec![t.id]).unwrap(); + assert_eq!((balance(&db, &a), balance(&db, &b)), (sa + 1000, sb), "deleting refunds the account it is on"); + } + #[test] fn transfer_moves_money_and_rejects_the_same_account() { let db = seeded(); diff --git a/src/ui/accounts.rs b/src/ui/accounts.rs index 23e4253..9526348 100644 --- a/src/ui/accounts.rs +++ b/src/ui/accounts.rs @@ -1,5 +1,5 @@ use super::widgets::*; -use chelete_lib::commands::{self, Account, CreateAccountRequest}; +use chelete_lib::commands::{self, Account, CreateAccountRequest, UpdateAccountRequest}; use chelete_lib::database::DbState; use chelete_lib::format::{account_type_label, format_balance, parse_cents}; use gpui_kit::base::input::InputState; @@ -14,8 +14,9 @@ use gpui_omarchy::{ use std::sync::Arc; enum Dialog { - Add(Entity), + Form(Entity), Transfer(Entity), + Confirm(Entity, String), } pub struct AccountsScreen { @@ -48,9 +49,13 @@ impl AccountsScreen { } pub fn open_add(&mut self, window: &mut Window, cx: &mut Context) { - let form = cx.new(|cx| AddAccountForm::new(self.db.clone(), window, cx)); + self.open_form(None, window, cx); + } + + fn open_form(&mut self, editing: Option, window: &mut Window, cx: &mut Context) { + let form = cx.new(|cx| AccountForm::new(self.db.clone(), editing, window, cx)); self._dialog_sub = Some(cx.subscribe(&form, Self::on_form_event)); - self.dialog = Some(Dialog::Add(form)); + self.dialog = Some(Dialog::Form(form)); cx.notify(); } @@ -79,10 +84,35 @@ impl AccountsScreen { cx.notify(); } + fn ask_delete(&mut self, account: &Account, cx: &mut Context) { + let dialog = cx.new(|cx| { + ConfirmDialog::new( + format!("Delete {}?", account.name), + "This will remove the account. Its transactions are kept. This action cannot be undone.", + "Delete", + cx, + ) + }); + self._dialog_sub = Some(cx.subscribe(&dialog, |this, _, event: &ConfirmEvent, cx| { + if *event == ConfirmEvent::Confirm { + if let Some(Dialog::Confirm(_, id)) = this.dialog.take() { + this.delete(id, cx); + } + } + this.dialog = None; + this._dialog_sub = None; + cx.notify(); + })); + self.dialog = Some(Dialog::Confirm(dialog, account.id.clone())); + cx.notify(); + } + fn delete(&mut self, id: String, cx: &mut Context) { + let name = self.accounts().iter().find(|a| a.id == id).map(|a| a.name.clone()); match commands::delete_account(&self.db, id) { Ok(()) => { self.accounts = commands::get_accounts(&self.db); + cx.emit(ScreenEvent::ok(format!("Deleted {}", name.unwrap_or_else(|| "account".into())))); } Err(e) => cx.emit(ScreenEvent::error(format!("Could not delete account: {e}"))), } @@ -115,7 +145,8 @@ impl Render for AccountsScreen { let cards: Vec<_> = list .iter() .map(|a| { - let id = a.id.clone(); + let account = a.clone(); + let editing = a.clone(); panel(account_type_label(&a.account_type), cx) .w(rems(16.)) .child(div().text_size(rems(0.875)).child(a.name.clone())) @@ -126,8 +157,17 @@ impl Render for AccountsScreen { .child(format_balance(a.balance)), ) .child( - button(SharedString::from(format!("delete-account-{}", a.id)), "Delete", ButtonVariant::Danger, cx) - .on_click(cx.listener(move |this, _, _, cx| this.delete(id.clone(), cx))), + div() + .flex() + .gap(rems(0.5)) + .child( + button(SharedString::from(format!("edit-account-{}", a.id)), "Edit", ButtonVariant::Outline, cx) + .on_click(cx.listener(move |this, _, window, cx| this.open_form(Some(editing.clone()), window, cx))), + ) + .child( + button(SharedString::from(format!("delete-account-{}", a.id)), "Delete", ButtonVariant::Danger, cx) + .on_click(cx.listener(move |this, _, _, cx| this.ask_delete(&account, cx))), + ), ) }) .collect(); @@ -136,8 +176,9 @@ impl Render for AccountsScreen { }; let dialog = self.dialog.as_ref().map(|d| match d { - Dialog::Add(form) => form.clone().into_any_element(), + Dialog::Form(form) => form.clone().into_any_element(), Dialog::Transfer(form) => form.clone().into_any_element(), + Dialog::Confirm(dialog, _) => dialog.clone().into_any_element(), }); div() @@ -162,26 +203,33 @@ pub enum FormEvent { Failed(String), } -pub struct AddAccountForm { +/// Adds a new account, or edits the name, type and balance of `editing`. +pub struct AccountForm { db: Arc, + editing: Option, name: Entity, kind: Entity, balance: Entity, _subs: Vec, } -impl EventEmitter for AddAccountForm {} - -impl AddAccountForm { - fn new(db: Arc, window: &mut Window, cx: &mut Context) -> Self { - let name = text_input("e.g. Main Bank", window, cx); - name.update(cx, |s, cx| s.focus(window, cx)); - let balance = text_input("0.00", window, cx); +impl EventEmitter for AccountForm {} + +impl AccountForm { + fn new(db: Arc, editing: Option, window: &mut Window, cx: &mut Context) -> Self { + let name_value = editing.as_ref().map(|a| a.name.clone()).unwrap_or_default(); + let balance_value = editing.as_ref().map(|a| format!("{:.2}", a.balance as f64 / 100.0)).unwrap_or_default(); + let kind = editing.as_ref().map(|a| a.account_type.clone()).unwrap_or_else(|| "cash".into()); + let name = text_input_with("e.g. Main Bank", &name_value, window, cx); + let balance = text_input_with("0.00", &balance_value, window, cx); + // When editing, the balance is usually what needs correcting. + if editing.is_some() { &balance } else { &name }.update(cx, |s, cx| s.focus(window, cx)); let _subs = submit_on_enter(&[&name, &balance], Self::submit, cx); Self { db, + editing, name, - kind: choice(account_type_options(), Some("cash"), window, cx), + kind: choice(account_type_options(), Some(&kind), window, cx), balance, _subs, } @@ -198,12 +246,29 @@ impl AddAccountForm { } else { match parse_cents(&balance_text) { Some(c) => c, - None => return cx.emit(FormEvent::Failed("Starting balance is not a valid amount".into())), + None => return cx.emit(FormEvent::Failed("Balance is not a valid amount".into())), } }; + let account_type = choice_value(&self.kind, cx).unwrap_or_else(|| "cash".into()); + if let Some(existing) = &self.editing { + let request = UpdateAccountRequest { + id: existing.id.clone(), + name: Some(name), + account_type: Some(account_type), + currency: None, + balance: Some(balance), + color: None, + icon: None, + is_active: None, + }; + return match commands::update_account(&self.db, request) { + Ok(a) => cx.emit(FormEvent::Saved(format!("Saved account {}", a.name))), + Err(e) => cx.emit(FormEvent::Failed(format!("Could not save account: {e}"))), + }; + } let request = CreateAccountRequest { name, - account_type: choice_value(&self.kind, cx).unwrap_or_else(|| "cash".into()), + account_type, currency: "USD".into(), balance, color: None, @@ -216,15 +281,16 @@ impl AddAccountForm { } } -impl Render for AddAccountForm { +impl Render for AccountForm { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let editing = self.editing.is_some(); let body = div() .flex() .flex_col() .gap(rems(0.75)) .child(field("Name", input("account-name", &self.name, window, cx), cx)) .child(field("Type", select("account-type", &self.kind, window, cx), cx)) - .child(field("Starting Balance", input("account-balance", &self.balance, window, cx), cx)) + .child(field(if editing { "Balance" } else { "Starting Balance" }, input("account-balance", &self.balance, window, cx), cx)) .child( div() .flex() @@ -235,11 +301,11 @@ impl Render for AddAccountForm { .on_click(cx.listener(|_, _, _, cx| cx.emit(FormEvent::Cancel))), ) .child( - button("add-submit", "Add", ButtonVariant::Primary, cx) + button("add-submit", if editing { "Save" } else { "Add" }, ButtonVariant::Primary, cx) .on_click(cx.listener(|this, _, _, cx| this.submit(cx))), ), ); - modal("Add Account", body, cx.listener(|_, _, _, cx| cx.emit(FormEvent::Cancel)), cx).on_action(cx.listener(|_, _: &super::ModalCancel, _, cx| cx.emit(FormEvent::Cancel))) + modal(if editing { "Edit Account" } else { "Add Account" }, body, cx.listener(|_, _, _, cx| cx.emit(FormEvent::Cancel)), cx).on_action(cx.listener(|_, _: &super::ModalCancel, _, cx| cx.emit(FormEvent::Cancel))) } } @@ -357,10 +423,10 @@ mod tests { (screen, vcx, events) } - fn open_add(screen: &Entity, cx: &mut VisualTestContext) -> Entity { + fn open_add(screen: &Entity, cx: &mut VisualTestContext) -> Entity { cx.update(|window, cx| screen.update(cx, |s, cx| s.open_add(window, cx))); cx.update(|_, cx| match screen.read(cx).dialog.as_ref() { - Some(Dialog::Add(f)) => f.clone(), + Some(Dialog::Form(f)) => f.clone(), _ => panic!("add form should be open"), }) } @@ -461,6 +527,93 @@ mod tests { }); } + fn ask_to_delete_first(screen: &Entity, cx: &mut VisualTestContext) -> (Account, Entity) { + let account = cx.update(|_, cx| screen.read(cx).accounts()[0].clone()); + cx.update(|_, cx| screen.update(cx, |s, cx| s.ask_delete(&account, cx))); + let dialog = cx.update(|_, cx| match screen.read(cx).dialog.as_ref() { + Some(Dialog::Confirm(d, id)) => { + assert_eq!(id, &account.id); + d.clone() + } + _ => panic!("confirm dialog should be open"), + }); + (account, dialog) + } + + #[gpui_kit::test] + fn delete_asks_before_removing_the_account(cx: &mut TestAppContext) { + let (screen, cx, _) = setup(cx); + let (account, dialog) = ask_to_delete_first(&screen, cx); + cx.update(|_, cx| assert_eq!(screen.read(cx).accounts().len(), 4, "nothing deleted until confirmed")); + cx.update(|_, cx| dialog.update(cx, |_, cx| cx.emit(ConfirmEvent::Confirm))); + cx.update(|_, cx| { + let s = screen.read(cx); + assert!(!s.has_dialog()); + assert_eq!(s.accounts().len(), 3); + assert!(s.accounts().iter().all(|a| a.id != account.id)); + }); + } + + #[gpui_kit::test] + fn cancelling_the_prompt_keeps_the_account(cx: &mut TestAppContext) { + let (screen, cx, _) = setup(cx); + let (account, dialog) = ask_to_delete_first(&screen, cx); + cx.update(|_, cx| dialog.update(cx, |_, cx| cx.emit(ConfirmEvent::Cancel))); + cx.update(|_, cx| { + let s = screen.read(cx); + assert!(!s.has_dialog()); + assert!(s.accounts().iter().any(|a| a.id == account.id)); + }); + } + + fn open_edit(screen: &Entity, account: &Account, cx: &mut VisualTestContext) -> Entity { + cx.update(|window, cx| screen.update(cx, |s, cx| s.open_form(Some(account.clone()), window, cx))); + cx.update(|_, cx| match screen.read(cx).dialog.as_ref() { + Some(Dialog::Form(f)) => f.clone(), + _ => panic!("edit form should be open"), + }) + } + + #[gpui_kit::test] + fn edit_corrects_the_balance_in_place(cx: &mut TestAppContext) { + let (screen, cx, _) = setup(cx); + let account = cx.update(|_, cx| screen.read(cx).accounts()[1].clone()); + let form = open_edit(&screen, &account, cx); + let (name, balance) = cx.update(|_, cx| { + let f = form.read(cx); + assert_eq!(f.name.read(cx).value().as_ref(), account.name); + assert_eq!(f.balance.read(cx).value().as_ref(), format!("{:.2}", account.balance as f64 / 100.0)); + (f.name.clone(), f.balance.clone()) + }); + type_into(&name, "Renamed", cx); + type_into(&balance, "1419.27", cx); + cx.update(|_, cx| form.update(cx, |f, cx| f.submit(cx))); + cx.update(|_, cx| { + let s = screen.read(cx); + assert!(!s.has_dialog()); + assert_eq!(s.accounts().len(), 4, "edited, not added"); + let saved = s.accounts().iter().find(|a| a.id == account.id).unwrap(); + assert_eq!((saved.name.as_str(), saved.balance), ("Renamed", 141927)); + assert_eq!(saved.account_type, account.account_type, "type kept"); + }); + } + + #[gpui_kit::test] + fn edit_with_an_invalid_balance_keeps_the_form_open(cx: &mut TestAppContext) { + let (screen, cx, events) = setup(cx); + let account = cx.update(|_, cx| screen.read(cx).accounts()[0].clone()); + let form = open_edit(&screen, &account, cx); + let balance = cx.update(|_, cx| form.read(cx).balance.clone()); + type_into(&balance, "lots", cx); + cx.update(|_, cx| form.update(cx, |f, cx| f.submit(cx))); + cx.update(|_, cx| { + let s = screen.read(cx); + assert!(s.has_dialog()); + assert_eq!(s.accounts().iter().find(|a| a.id == account.id).unwrap().balance, account.balance); + }); + assert!(matches!(events.borrow().last(), Some(ScreenEvent::Toast { ok: false, .. }))); + } + #[gpui_kit::test] fn escape_closes_the_open_form(cx: &mut TestAppContext) { let (screen, cx, _) = setup(cx); diff --git a/src/ui/date_filter_control.rs b/src/ui/date_filter_control.rs index 70239bd..7885547 100644 --- a/src/ui/date_filter_control.rs +++ b/src/ui/date_filter_control.rs @@ -7,6 +7,9 @@ use gpui_kit::component::date_picker::{DatePicker, DatePickerState, DateRangePre use gpui_kit::{div, prelude::*, rems, App, Context, Div, Entity, Subscription, Window}; use gpui_omarchy::{select, ChoiceState}; +/// Width of the preset dropdown, for fields that sit beside it. +pub const PRESET_WIDTH: f32 = 13.; + pub struct DateFilterControl { pub preset: Entity, pub range: Entity, @@ -49,7 +52,7 @@ impl DateFilterControl { .flex() .items_center() .gap(rems(0.5)) - .child(div().w(rems(13.)).child(select("date-filter", &self.preset, window, cx))) + .child(div().w(rems(PRESET_WIDTH)).debug_selector(|| "date-filter".into()).child(select("date-filter", &self.preset, window, cx))) .when(custom, |row| { row.child( div().w(rems(22.)).child( diff --git a/src/ui/subscriptions.rs b/src/ui/subscriptions.rs index 4ec217c..cb2288d 100644 --- a/src/ui/subscriptions.rs +++ b/src/ui/subscriptions.rs @@ -13,7 +13,7 @@ use chelete_lib::subscriptions::{filter_by_frequency, frequency_label, next_paym use gpui_kit::base::input::InputState; use gpui_kit::component::date_picker::{DatePicker, DatePickerState}; use gpui_kit::{ - div, prelude::*, px, rems, Context, Entity, EventEmitter, IntoElement, Render, + div, prelude::*, px, rems, App, Context, Entity, EventEmitter, IntoElement, Render, SharedString, Subscription as GpuiSubscription, Window, }; use gpui_omarchy::{ @@ -222,6 +222,32 @@ impl SubscriptionsScreen { cx.notify(); } + fn set_active(&mut self, sub: &Subscription, active: bool, cx: &mut Context) { + let result = commands::update_subscription( + &self.db, + UpdateSubscriptionRequest { + id: sub.id.clone(), + name: None, + amount: None, + currency: None, + frequency: None, + category_id: None, + account_id: None, + start_date: None, + is_active: Some(active), + }, + ); + match result { + Ok(_) => { + self.load(); + let state = if active { "active" } else { "inactive" }; + cx.emit(ScreenEvent::ok(format!("Marked {} as {state}", sub.name))); + } + Err(e) => cx.emit(ScreenEvent::error(format!("Could not update subscription: {e}"))), + } + cx.notify(); + } + fn toggle(&mut self, id: &str, cx: &mut Context) { if !self.selected.remove(id) { self.selected.insert(id.to_string()); @@ -300,6 +326,7 @@ impl Render for SubscriptionsScreen { let screen = cx.entity(); move |_, cx| screen.update(cx, |s, cx| s.toggle_page(cx)) }, cx))) + .child(cell(Some(1.5), "")) .child(cell(None, "Name")) .child(cell(None, "Category")) .child(cell(None, "Account")) @@ -318,7 +345,7 @@ impl Render for SubscriptionsScreen { let checked = self.selected.contains(&s.id); let row_id = s.id.clone(); let key = |prefix: &str| SharedString::from(format!("{prefix}-{}", s.id)); - let (view, edit, pay, del) = (s.clone(), s.clone(), s.clone(), s.id.clone()); + let (view, edit, pay, del, status) = (s.clone(), s.clone(), s.clone(), s.id.clone(), s.clone()); list = list.child( div() .flex() @@ -331,6 +358,10 @@ impl Render for SubscriptionsScreen { let screen = cx.entity(); move |_, cx| screen.update(cx, |s, cx| s.toggle(&row_id, cx)) }, cx))) + .child(cell(Some(1.5), status_dot(s.is_active, cx) + .id(key("status")) + .cursor_pointer() + .on_click(cx.listener(move |this, _, _, cx| this.set_active(&status, !status.is_active, cx))))) .child(cell(None, s.name.clone())) .child(cell(None, match &category { Some(c) => div() @@ -424,6 +455,20 @@ impl SubscriptionsScreen { .flex_col() .gap(rems(0.5)) .child(row("Name", s.name.clone())) + .child( + div() + .flex() + .justify_between() + .child(dim("Status", cx)) + .child( + div() + .flex() + .items_center() + .gap(rems(0.375)) + .child(status_dot(s.is_active, cx)) + .child(if s.is_active { "Active" } else { "Inactive" }), + ), + ) .child(row("Amount", format_money(s.amount))) .child(row("Period", frequency_label(&s.frequency))) .child(row("Start Date", s.start_date.clone().unwrap_or_else(|| "—".into()))) @@ -450,6 +495,7 @@ pub struct SubscriptionForm { category: Entity, account: Entity, start_date: Entity, + status: Entity, _subs: Vec, } @@ -470,6 +516,8 @@ impl SubscriptionForm { account_items.extend(accounts.iter().map(|a| (a.id.clone(), a.name.clone()))); let frequency_items = FREQUENCIES.iter().map(|(v, l)| (v.to_string(), l.to_string())).collect(); + let status = if editing.as_ref().map_or(true, |s| s.is_active) { "active" } else { "inactive" }; + let status_items = vec![("active".to_string(), "Active".to_string()), ("inactive".to_string(), "Inactive".to_string())]; let (name, amount, frequency, category, account, start) = match &editing { Some(s) => ( s.name.clone(), @@ -494,6 +542,7 @@ impl SubscriptionForm { category: choice(category_items, Some(&category), window, cx), account: choice(account_items, Some(&account), window, cx), start_date: date_field::single(start, window, cx), + status: choice(status_items, Some(status), window, cx), _subs, } } @@ -538,7 +587,7 @@ impl SubscriptionForm { category_id: Some(category_id), account_id: Some(account_id), start_date: Some(start_date), - is_active: None, + is_active: Some(choice_value(&self.status, cx).as_deref() != Some("inactive")), }, ) .map(|_| format!("Saved subscription {name}")), @@ -568,6 +617,7 @@ impl Render for SubscriptionForm { .child(field("Category", select("sub-category", &self.category, window, cx), cx)) .child(field("Account", select("sub-account", &self.account, window, cx), cx)) .child(field("Start Date", DatePicker::new(&self.start_date).placeholder("Pick a date").cleanable(true), cx)) + .children(editing.then(|| field("Status", select("sub-status", &self.status, window, cx), cx))) .child( div() .flex() @@ -585,6 +635,15 @@ impl Render for SubscriptionForm { } } +/// Green for an active subscription, yellow for an inactive one. +fn status_dot(active: bool, cx: &App) -> gpui_kit::Div { + let t = cx.omarchy(); + div() + .size(rems(0.625)) + .rounded_full() + .bg(if active { t.success } else { t.warning }) +} + #[cfg(test)] mod tests { use super::*; @@ -780,6 +839,29 @@ mod tests { }); } + #[gpui_kit::test] + fn subscriptions_can_be_marked_inactive_and_active_again(cx: &mut TestAppContext) { + let (screen, db, cx) = setup(cx); + let sub = make(&db, "Gym", 3000, "monthly"); + reload(&screen, cx); + cx.update(|_, cx| screen.update(cx, |s, cx| s.set_active(&sub, false, cx))); + cx.update(|_, cx| assert!(!screen.read(cx).subscriptions()[0].is_active)); + + // The edit form starts from the saved status and can flip it back. + let saved = cx.update(|_, cx| screen.read(cx).subscriptions()[0].clone()); + cx.update(|window, cx| screen.update(cx, |s, cx| s.open_form(Some(saved), window, cx))); + let form = form_of(&screen, cx); + let status = cx.update(|_, cx| { + let f = form.read(cx); + assert_eq!(choice_value(&f.status, cx).as_deref(), Some("inactive")); + f.status.clone() + }); + // Items: active, inactive. + cx.update(|_, cx| status.update(cx, |c, cx| c.set_selected(Some(0), cx))); + cx.update(|_, cx| form.update(cx, |f, cx| f.submit(cx))); + cx.update(|_, cx| assert!(screen.read(cx).subscriptions()[0].is_active)); + } + #[gpui_kit::test] fn open_add_shows_the_form(cx: &mut TestAppContext) { let (screen, _db, cx) = setup(cx); diff --git a/src/ui/transactions.rs b/src/ui/transactions.rs index 0cdc486..05cec0b 100644 --- a/src/ui/transactions.rs +++ b/src/ui/transactions.rs @@ -1,7 +1,7 @@ use super::accounts::FormEvent; use super::csv_import::{CsvImportDialog, CsvImportEvent}; use super::date_field; -use super::date_filter_control::DateFilterControl; +use super::date_filter_control::{DateFilterControl, PRESET_WIDTH}; use super::icons::category_icon; use super::widgets::*; use chelete_lib::commands::{ @@ -10,7 +10,7 @@ use chelete_lib::commands::{ use chelete_lib::database::DbState; use chelete_lib::date_filter::is_date_in_range; use chelete_lib::export::ExportFormat; -use chelete_lib::format::{format_money, parse_cents}; +use chelete_lib::format::{format_balance, format_money, parse_cents}; use chelete_lib::paging::{page_slice, Pager}; use gpui_kit::base::input::InputState; use gpui_kit::component::date_picker::DatePicker; @@ -40,6 +40,7 @@ pub struct TransactionsScreen { accounts: Vec, categories: Vec, date_filter: DateFilterControl, + search: Entity, per_page: Entity, applied_per_page: usize, page: usize, @@ -57,10 +58,15 @@ impl TransactionsScreen { pub fn new(db: Arc, window: &mut Window, cx: &mut Context) -> Self { let date_filter = DateFilterControl::new(window, cx); let per_page = per_page_choice(25, window, cx); + let search = text_input("Search transactions", window, cx); let mut subs = date_filter.observe(cx, |this: &mut Self, cx| { this.page = 1; cx.notify(); }); + subs.push(cx.observe(&search, |this, _, cx| { + this.page = 1; + cx.notify(); + })); subs.push(cx.observe(&per_page, |this, _, cx| { let n = choice_value(&this.per_page, cx).and_then(|v| v.parse().ok()).unwrap_or(this.applied_per_page); if n != this.applied_per_page { @@ -75,6 +81,7 @@ impl TransactionsScreen { accounts: Vec::new(), categories: Vec::new(), date_filter, + search, per_page, applied_per_page: 25, page: 1, @@ -109,12 +116,32 @@ impl TransactionsScreen { fn filtered(&self, cx: &gpui_kit::App) -> Vec<&Transaction> { let (start, end) = self.date_filter.bounds(chrono::Local::now().date_naive(), cx); + let query = text_of(&self.search, cx).to_lowercase(); + let terms: Vec<&str> = query.split_whitespace().collect(); self.transactions .iter() .filter(|t| is_date_in_range(&t.transaction_date, &start, &end)) + .filter(|t| terms.is_empty() || self.matches_search(t, &terms)) .collect() } + /// Every term has to appear in at least one of the searchable fields. + fn matches_search(&self, t: &Transaction, terms: &[&str]) -> bool { + let haystack = [ + Some(t.description.as_str()), + t.merchant.as_deref(), + Some(t.transaction_type.as_str()), + self.category(&t.category_id).map(|c| c.name.as_str()), + self.account_name(&t.account_id), + ] + .into_iter() + .flatten() + .map(str::to_lowercase) + .collect::>() + .join("\n"); + terms.iter().all(|term| haystack.contains(term)) + } + fn pager(&self, cx: &gpui_kit::App) -> Pager { Pager::new(self.page, self.applied_per_page, self.filtered(cx).len()) } @@ -319,7 +346,7 @@ impl Render for TransactionsScreen { }; let body = if rows.is_empty() { - let text = if none_at_all { "No transactions yet. Add one to get started." } else { "No transactions match the selected filters." }; + let text = if none_at_all { "No transactions yet. Add one to get started." } else { "No transactions match the search and filters." }; div().py(rems(3.)).flex().justify_center().text_color(t.secondary).child(text).into_any_element() } else { let header = div() @@ -416,7 +443,19 @@ impl Render for TransactionsScreen { Dialog::Import(i) => i.clone().into_any_element(), Dialog::View(tx) => self.view_dialog(tx, cx).into_any_element(), }); - let filter_row = self.date_filter.render(today, window, cx); + let filter_row = self + .date_filter + .render(today, window, cx) + .child( + // Stretch to the date dropdown's height so the two line up. + div().w(rems(PRESET_WIDTH)).self_stretch().child( + input("tx-search", &self.search, window, cx) + .debug_selector(|| "tx-search".into()) + .h_full() + .py(rems(0.)) + .prefix(icon(IconName::Search).size(rems(0.875)).text_color(t.secondary)), + ), + ); div() .id("transactions") @@ -471,17 +510,27 @@ impl TransactionsScreen { pub struct TransactionForm { db: Arc, + accounts: Vec, categories: Vec, editing: Option, kind: String, amount: Entity, description: Entity, + balance_left: Entity, account: Entity, category: Entity, date: Entity, _subs: Vec, } +/// What the balance left after a mobile money payment says about its fee. +#[derive(Debug, PartialEq)] +enum Charges { + Unknown, + Fee(i64), + TooHigh, +} + impl EventEmitter for TransactionForm {} fn category_items(categories: &[Category], kind: &str) -> Vec<(String, String)> { @@ -511,14 +560,22 @@ impl TransactionForm { let amount_input = text_input_with("0.00", &amount, window, cx); amount_input.update(cx, |s, cx| s.focus(window, cx)); let description_input = text_input_with("e.g. Groceries", &description, window, cx); - let _subs = submit_on_enter(&[&amount_input, &description_input], Self::submit, cx); + let balance_left = text_input_with("From the confirmation SMS", "", window, cx); + let account = choice(accounts.iter().map(|a| (a.id.clone(), a.name.clone())).collect(), account_id.as_deref(), window, cx); + let mut _subs = submit_on_enter(&[&amount_input, &description_input, &balance_left], Self::submit, cx); + // The charges preview depends on these, so redraw when they change. + _subs.push(cx.observe(&amount_input, |_, _, cx| cx.notify())); + _subs.push(cx.observe(&balance_left, |_, _, cx| cx.notify())); + _subs.push(cx.observe(&account, |_, _, cx| cx.notify())); Self { db, + accounts: accounts.to_vec(), categories: categories.to_vec(), kind: kind.clone(), amount: amount_input, description: description_input, - account: choice(accounts.iter().map(|a| (a.id.clone(), a.name.clone())).collect(), account_id.as_deref(), window, cx), + balance_left, + account, category: choice(category_items(categories, &kind), Some(&category_id), window, cx), date: date_field::single(Some(date), window, cx), editing, @@ -536,6 +593,39 @@ impl TransactionForm { cx.notify(); } + /// The account a new expense is paid from, when it takes mobile money + /// charges. Edits are left alone: the balance already includes them. + fn charges_account(&self, cx: &gpui_kit::App) -> Option<&Account> { + if self.editing.is_some() || self.kind != "expense" { + return None; + } + let id = choice_value(&self.account, cx)?; + self.accounts.iter().find(|a| a.id == id).filter(|a| commands::charges_apply(a)) + } + + fn charges(&self, cx: &gpui_kit::App) -> Charges { + let Some(account) = self.charges_account(cx) else { return Charges::Unknown }; + let (Some(spent), Some(left)) = (parse_cents(&text_of(&self.amount, cx)), parse_cents(&text_of(&self.balance_left, cx))) else { + return Charges::Unknown; + }; + match commands::charges_from_balance_left(account.balance, spent, left) { + Some(fee) => Charges::Fee(fee), + None => Charges::TooHigh, + } + } + + /// An expense category for fees, when the user keeps one. + fn charges_category(&self) -> Option { + self.categories + .iter() + .filter(|c| c.category_type == "expense") + .find(|c| { + let name = c.name.to_lowercase(); + name.contains("fee") || name.contains("charge") + }) + .map(|c| c.id.clone()) + } + fn submit(&mut self, cx: &mut Context) { let amount_text = text_of(&self.amount, cx); let cents = match parse_cents(&amount_text) { @@ -545,6 +635,11 @@ impl TransactionForm { let Some(account_id) = choice_value(&self.account, cx) else { return cx.emit(FormEvent::Failed("Add an account first".into())); }; + let charges = match self.charges(cx) { + Charges::TooHigh => return cx.emit(FormEvent::Failed("Balance left is more than the account would have after this payment".into())), + Charges::Fee(fee) if fee > 0 => Some(fee), + _ => None, + }; let category_id = choice_value(&self.category, cx).filter(|c| !c.is_empty()); let description = text_of(&self.description, cx); let date = date_field::picked(&self.date, cx) @@ -556,18 +651,35 @@ impl TransactionForm { None => commands::create_transaction( &self.db, CreateTransactionRequest { - account_id, + account_id: account_id.clone(), category_id, transaction_type: self.kind.clone(), amount: cents, currency: "USD".into(), - description, + description: description.clone(), merchant: None, notes: None, - transaction_date: date, + transaction_date: date.clone(), }, ) - .map(|_| "Transaction added".to_string()), + .and_then(|_| match charges { + None => Ok("Transaction added".to_string()), + Some(fee) => commands::create_transaction( + &self.db, + CreateTransactionRequest { + account_id, + category_id: self.charges_category(), + transaction_type: "expense".into(), + amount: fee, + currency: "USD".into(), + description: "Ecocash charges".into(), + merchant: None, + notes: (!description.is_empty()).then(|| format!("Charges on: {description}")), + transaction_date: date, + }, + ) + .map(|_| format!("Transaction added with {} charges", format_balance(fee))), + }), Some(existing) => commands::update_transaction( &self.db, UpdateTransactionRequest { @@ -620,6 +732,14 @@ impl Render for TransactionForm { .child(field("Account", select("tx-account", &self.account, window, cx), cx)) .child(field("Category", select("tx-category", &self.category, window, cx), cx)), ) + .children(self.charges_account(cx).map(|account| { + let hint = match self.charges(cx) { + Charges::Unknown => dim(format!("Optional. Charges are worked out from the {} balance of {}.", account.name, format_balance(account.balance)), cx), + Charges::Fee(fee) => dim(format!("Charges: {}", format_balance(fee)), cx), + Charges::TooHigh => div().text_color(cx.omarchy().danger).child("More than the account would have after this payment"), + }; + field("Balance left", div().flex().flex_col().gap(rems(0.25)).child(input("tx-balance-left", &self.balance_left, window, cx)).child(hint), cx) + })) .child(field("Date", DatePicker::new(&self.date).placeholder("Pick a date"), cx)) .child( div() @@ -787,6 +907,79 @@ mod tests { assert_eq!(after.balance, account.balance - 4250); } + fn add_ecocash_account(db: &DbState, balance: i64) -> Account { + commands::create_account( + db, + commands::CreateAccountRequest { + name: "Ecocash".into(), + account_type: "mobile_money".into(), + currency: "USD".into(), + balance, + color: None, + icon: None, + }, + ) + .unwrap() + } + + fn open_form_on(screen: &Entity, account: &Account, cx: &mut VisualTestContext) -> Entity { + cx.update(|window, cx| screen.update(cx, |s, cx| { s.reload(cx); s.open_add(window, cx) })); + let form = form_of(screen, cx); + let index = cx.update(|_, cx| form.read(cx).accounts.iter().position(|a| a.id == account.id).unwrap()); + let choice = cx.update(|_, cx| form.read(cx).account.clone()); + cx.update(|_, cx| choice.update(cx, |c, cx| c.set_selected(Some(index), cx))); + form + } + + #[gpui_kit::test] + fn ecocash_balance_left_records_the_charges(cx: &mut TestAppContext) { + let (screen, db, cx) = setup(cx); + let account = add_ecocash_account(&db, 100_00); + let form = open_form_on(&screen, &account, cx); + let (amount, description, left) = cx.update(|_, cx| { + let f = form.read(cx); + (f.amount.clone(), f.description.clone(), f.balance_left.clone()) + }); + type_into(&amount, "20", cx); + type_into(&description, "Airtime", cx); + type_into(&left, "79.40", cx); + cx.update(|_, cx| assert_eq!(form.read(cx).charges(cx), Charges::Fee(60))); + cx.update(|_, cx| form.update(cx, |f, cx| f.submit(cx))); + cx.update(|_, cx| { + let s = screen.read(cx); + assert!(!s.has_dialog()); + let fee = s.transactions().iter().find(|t| t.description == "Ecocash charges").expect("charges recorded"); + assert_eq!(fee.amount, 60); + assert_eq!(fee.account_id, account.id); + assert_eq!(fee.notes.as_deref(), Some("Charges on: Airtime")); + }); + let after = commands::get_accounts(&db).unwrap().into_iter().find(|a| a.id == account.id).unwrap(); + assert_eq!(after.balance, 79_40, "balance matches the SMS"); + } + + #[gpui_kit::test] + fn ecocash_balance_left_above_the_possible_is_rejected(cx: &mut TestAppContext) { + let (screen, db, cx) = setup(cx); + let account = add_ecocash_account(&db, 100_00); + let form = open_form_on(&screen, &account, cx); + let (amount, left) = cx.update(|_, cx| (form.read(cx).amount.clone(), form.read(cx).balance_left.clone())); + type_into(&amount, "20", cx); + type_into(&left, "90", cx); + cx.update(|_, cx| form.update(cx, |f, cx| f.submit(cx))); + cx.update(|_, cx| { + assert!(screen.read(cx).has_dialog()); + assert_eq!(screen.read(cx).transactions().len(), 25); + }); + } + + #[gpui_kit::test] + fn balance_left_is_only_offered_for_mobile_money(cx: &mut TestAppContext) { + let (screen, _db, cx) = setup(cx); + cx.update(|window, cx| screen.update(cx, |s, cx| s.open_add(window, cx))); + let form = form_of(&screen, cx); + cx.update(|_, cx| assert!(form.read(cx).charges_account(cx).is_none(), "seeded first account is a bank")); + } + #[gpui_kit::test] fn zero_or_blank_amount_is_ignored(cx: &mut TestAppContext) { let (screen, _db, cx) = setup(cx); @@ -894,6 +1087,63 @@ mod tests { cx.update(|_, cx| assert_eq!(screen.read(cx).page, 1)); } + fn search_for(screen: &Entity, text: &str, cx: &mut VisualTestContext) -> Vec { + let search = cx.update(|_, cx| screen.read(cx).search.clone()); + type_into(&search, text, cx); + cx.update(|_, cx| screen.read(cx).filtered(cx).into_iter().cloned().collect()) + } + + #[gpui_kit::test] + fn search_matches_description_type_category_and_account(cx: &mut TestAppContext) { + let (screen, db, cx) = setup(cx); + let all = cx.update(|_, cx| screen.read(cx).transactions().to_vec()); + let sample = all.iter().find(|t| t.category_id.is_some()).unwrap().clone(); + + let word = sample.description.split_whitespace().next().unwrap().to_uppercase(); + let shown = search_for(&screen, &word, cx); + assert!(shown.iter().any(|t| t.id == sample.id), "description, case-insensitive"); + + let shown = search_for(&screen, "income", cx); + assert!(!shown.is_empty()); + assert!(shown.len() < all.len()); + assert_eq!(shown.len(), all.iter().filter(|t| t.transaction_type == "income").count(), "seeded descriptions do not say income"); + + let category = commands::get_categories(&db).unwrap().into_iter().find(|c| Some(&c.id) == sample.category_id.as_ref()).unwrap(); + let shown = search_for(&screen, &category.name, cx); + assert!(shown.iter().any(|t| t.id == sample.id), "category name"); + + let account = commands::get_accounts(&db).unwrap().into_iter().find(|a| a.id == sample.account_id).unwrap(); + let shown = search_for(&screen, &account.name, cx); + assert!(shown.iter().any(|t| t.id == sample.id), "account name"); + assert!(shown.len() < all.len(), "other accounts are left out"); + } + + #[gpui_kit::test] + fn search_terms_must_all_match_and_reset_the_page(cx: &mut TestAppContext) { + let (screen, db, cx) = setup(cx); + let sample = cx.update(|_, cx| screen.read(cx).transactions()[0].clone()); + let account = commands::get_accounts(&db).unwrap().into_iter().find(|a| a.id == sample.account_id).unwrap(); + cx.update(|_, cx| screen.update(cx, |s, cx| { s.page = 2; cx.notify(); })); + + let shown = search_for(&screen, &format!("{} {}", sample.transaction_type, account.name), cx); + assert!(shown.iter().any(|t| t.id == sample.id)); + let either = search_for(&screen, &sample.transaction_type, cx).len().min(search_for(&screen, &account.name, cx).len()); + assert!(shown.len() <= either, "both terms narrow the list"); + cx.update(|_, cx| assert_eq!(screen.read(cx).page, 1)); + + assert!(search_for(&screen, "zzz-no-such-thing", cx).is_empty()); + assert_eq!(search_for(&screen, " ", cx).len(), 25, "blank search shows everything"); + } + + #[gpui_kit::test] + fn search_box_matches_the_date_dropdown(cx: &mut TestAppContext) { + let (_screen, _db, cx) = setup(cx); + cx.update(|window, cx| window.draw(cx).clear(cx)); + let date = cx.debug_bounds("date-filter").expect("date dropdown drawn"); + let search = cx.debug_bounds("tx-search").expect("search box drawn"); + assert_eq!(search.size, date.size); + } + #[gpui_kit::test] fn per_page_paginates_the_list(cx: &mut TestAppContext) { let (screen, _db, cx) = setup(cx);