Skip to content
3 changes: 1 addition & 2 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,8 @@ Features that build directly on what exists today.
- Surface "due soon" subscriptions on the Overview dashboard.
- One-click "Pay now" to create a real transaction from a subscription.

- [ ] **Transaction search**
- [x] **Transaction search**
- Search Recent Transactions by description, merchant, category, or amount.
- Done: description, merchant, type, category and account. Still to do: amount.

- [x] **Auto calculate charges for Ecocash**
- For payments and tranfers done using Ecocash specify amount spend and left amount then auto calculate charges
Expand Down
95 changes: 93 additions & 2 deletions src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -286,16 +286,42 @@ pub fn update_account(
Ok(account)
}

/// Deletes the account along with its transactions, so they stop counting
/// towards totals. Subscriptions billed to it are kept but unlinked.
pub fn delete_account(state: &DbState, id: String) -> Result<(), String> {
let conn = state.0.lock().map_err(|e| e.to_string())?;
conn.execute(
let mut conn = state.0.lock().map_err(|e| e.to_string())?;
let tx = conn.transaction().map_err(|e| e.to_string())?;
tx.execute(
"UPDATE accounts SET deleted_at = datetime('now') WHERE id = ?1",
params![id],
)
.map_err(|e| e.to_string())?;
tx.execute(
"UPDATE transactions SET deleted_at = datetime('now') WHERE account_id = ?1 AND deleted_at IS NULL",
params![id],
)
.map_err(|e| e.to_string())?;
tx.execute(
"UPDATE subscriptions SET account_id = NULL, updated_at = datetime('now') WHERE account_id = ?1",
params![id],
)
.map_err(|e| e.to_string())?;
tx.commit().map_err(|e| e.to_string())?;
Ok(())
}

/// How many live transactions belong to an account.
pub fn count_account_transactions(state: &DbState, id: &str) -> Result<usize, String> {
let conn = state.0.lock().map_err(|e| e.to_string())?;
conn.query_row(
"SELECT COUNT(*) FROM transactions WHERE account_id = ?1 AND deleted_at IS NULL",
params![id],
|row| row.get::<_, i64>(0),
)
.map(|n| n as usize)
.map_err(|e| e.to_string())
}

fn get_account_by_id(conn: &rusqlite::Connection, id: &str) -> Result<Account, String> {
conn.query_row(
"SELECT id, name, account_type, currency, balance, color, icon, is_active, sort_order, created_at, updated_at
Expand Down Expand Up @@ -1752,4 +1778,69 @@ mod tests {
assert_eq!(overview.total_balance, accounts_total);
assert!(overview.total_income > 0 && overview.total_expenses > 0);
}

/// The seeded account with the most transactions.
fn busiest_account(db: &DbState) -> String {
let txs = get_transactions(db).unwrap();
get_accounts(db)
.unwrap()
.into_iter()
.max_by_key(|a| txs.iter().filter(|t| t.account_id == a.id).count())
.unwrap()
.id
}

#[test]
fn deleting_an_account_hides_its_transactions() {
let db = seeded();
let id = busiest_account(&db);
let before = get_transactions(&db).unwrap();
let owned = before.iter().filter(|t| t.account_id == id).count();
assert!(owned > 0, "seed gives the account some transactions");
assert_eq!(count_account_transactions(&db, &id).unwrap(), owned);

delete_account(&db, id.clone()).unwrap();

let after = get_transactions(&db).unwrap();
assert_eq!(after.len(), before.len() - owned);
assert!(after.iter().all(|t| t.account_id != id));
assert!(get_overview(&db).unwrap().recent_transactions.iter().all(|t| t.account_id != id));
assert_eq!(count_account_transactions(&db, &id).unwrap(), 0);
}

#[test]
fn deleting_an_account_keeps_its_subscriptions_unlinked() {
let db = seeded();
let id = busiest_account(&db);
let sub = create_subscription(
&db,
CreateSubscriptionRequest {
name: "Streaming".into(),
amount: 999,
currency: "USD".into(),
frequency: "monthly".into(),
category_id: None,
account_id: Some(id.clone()),
start_date: None,
},
)
.unwrap();

delete_account(&db, id).unwrap();

let kept = get_subscriptions(&db).unwrap().into_iter().find(|s| s.id == sub.id).expect("subscription kept");
assert_eq!(kept.account_id, None);
}

#[test]
fn migrations_clean_up_accounts_deleted_the_old_way() {
let db = seeded();
let id = busiest_account(&db);
{
let conn = db.0.lock().unwrap();
conn.execute("UPDATE accounts SET deleted_at = datetime('now') WHERE id = ?1", params![id]).unwrap();
crate::database::run_migrations(&conn).unwrap();
}
assert!(get_transactions(&db).unwrap().iter().all(|t| t.account_id != id));
}
}
12 changes: 12 additions & 0 deletions src/database/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,18 @@ pub fn run_migrations(conn: &Connection) -> Result<()> {
",
)?;

// Accounts deleted before deletes cascaded left their transactions behind.
conn.execute_batch(
"
UPDATE transactions SET deleted_at = datetime('now')
WHERE deleted_at IS NULL
AND account_id IN (SELECT id FROM accounts WHERE deleted_at IS NOT NULL);

UPDATE subscriptions SET account_id = NULL
WHERE account_id IN (SELECT id FROM accounts WHERE deleted_at IS NOT NULL);
",
)?;

Ok(())
}

Expand Down
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ pub mod prefs;
pub mod projection;
pub mod seed;
pub mod subscriptions;
pub mod transaction_filter;
Loading
Loading