Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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/*
11 changes: 6 additions & 5 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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**
Expand Down
18 changes: 18 additions & 0 deletions scripts/release-notes.sh
Original file line number Diff line number Diff line change
@@ -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 <tag>}"
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#^[email protected]:#https://github.com/#; s#\.git$##')
echo
echo "**Full Changelog**: ${REPO_URL}/compare/${PREV}...${TAG}"
fi
110 changes: 86 additions & 24 deletions src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<i64> {
let charges = balance - spent - left;
(charges >= 0).then_some(charges)
}

pub fn update_transaction(
state: &DbState,
request: UpdateTransactionRequest,
Expand All @@ -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
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down
Loading
Loading