Compare commits

...

7 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
4c8056858c fix(frontend): Correct running balance calculation in transactions table
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-15 23:31:33 +00:00
copilot-swe-agent[bot]
1cfc5f0422 Initial plan 2025-09-15 23:22:37 +00:00
Elisiário Couto
bfb5a7ef76 chore(ci): Bump version to 2025.9.12 2025-09-16 00:14:10 +01:00
copilot-swe-agent[bot]
95b3b93a8a Restore original package.json dev script with VITE_API_URL
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-16 00:12:50 +01:00
Elisiário Couto
9a2199873c Delete frontend/.env.development 2025-09-16 00:12:50 +01:00
copilot-swe-agent[bot]
82a12dadad Complete display_name feature with frontend integration and testing
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-16 00:12:50 +01:00
copilot-swe-agent[bot]
33a7ad5ad2 Implement display_name field with migration and API support
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-16 00:12:50 +01:00
14 changed files with 392 additions and 36 deletions

View File

@@ -1,4 +1,10 @@
## 2025.9.12 (2025/09/15)
## 2025.9.12 (2025/09/15)
## 2025.9.11 (2025/09/15)
### Bug Fixes

View File

@@ -81,8 +81,8 @@ export default function AccountsOverview() {
const queryClient = useQueryClient();
const updateAccountMutation = useMutation({
mutationFn: ({ id, name }: { id: string; name: string }) =>
apiClient.updateAccount(id, { name }),
mutationFn: ({ id, display_name }: { id: string; display_name: string }) =>
apiClient.updateAccount(id, { display_name }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["accounts"] });
setEditingAccountId(null);
@@ -95,14 +95,15 @@ export default function AccountsOverview() {
const handleEditStart = (account: Account) => {
setEditingAccountId(account.id);
setEditingName(account.name || "");
// Use display_name if available, otherwise fall back to name
setEditingName(account.display_name || account.name || "");
};
const handleEditSave = () => {
if (editingAccountId && editingName.trim()) {
updateAccountMutation.mutate({
id: editingAccountId,
name: editingName.trim(),
display_name: editingName.trim(),
});
}
};
@@ -267,7 +268,7 @@ export default function AccountsOverview() {
setEditingName(e.target.value)
}
className="flex-1 px-3 py-1 text-base sm:text-lg font-medium border border-input rounded-md bg-background focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring"
placeholder="Account name"
placeholder="Custom account name"
name="search"
autoComplete="off"
onKeyDown={(e) => {
@@ -303,7 +304,7 @@ export default function AccountsOverview() {
<div>
<div className="flex items-center space-x-2 min-w-0">
<h4 className="text-base sm:text-lg font-medium text-foreground truncate">
{account.name || "Unnamed Account"}
{account.display_name || account.name || "Unnamed Account"}
</h4>
<button
onClick={() => handleEditStart(account)}

View File

@@ -192,9 +192,9 @@ export default function TransactionsTable() {
const runningBalances: { [key: string]: number } = {};
const accountBalanceMap = new Map<string, number>();
// Create a map of account current balances
// Create a map of account current balances - use interimAvailable as the most current
balances.forEach((balance) => {
if (balance.balance_type === "expected") {
if (balance.balance_type === "interimAvailable") {
accountBalanceMap.set(balance.account_id, balance.balance_amount);
}
});
@@ -211,20 +211,25 @@ export default function TransactionsTable() {
// Calculate running balance for each account
transactionsByAccount.forEach((accountTransactions, accountId) => {
const currentBalance = accountBalanceMap.get(accountId) || 0;
let runningBalance = currentBalance;
// Sort transactions by date (newest first) to work backwards
// Sort transactions by date (oldest first) for forward calculation
const sortedTransactions = [...accountTransactions].sort(
(a, b) =>
new Date(b.transaction_date).getTime() -
new Date(a.transaction_date).getTime(),
new Date(a.transaction_date).getTime() -
new Date(b.transaction_date).getTime(),
);
// Calculate running balance by working backwards from current balance
// Calculate the starting balance by working backwards from current balance
let startingBalance = currentBalance;
for (let i = sortedTransactions.length - 1; i >= 0; i--) {
startingBalance -= sortedTransactions[i].transaction_value;
}
// Now calculate running balances going forward chronologically
let runningBalance = startingBalance;
sortedTransactions.forEach((txn) => {
runningBalances[`${txn.account_id}-${txn.transaction_id}`] =
runningBalance;
runningBalance -= txn.transaction_value;
runningBalance += txn.transaction_value;
runningBalances[`${txn.account_id}-${txn.transaction_id}`] = runningBalance;
});
});

View File

@@ -38,7 +38,7 @@ export function AccountCombobox({
);
const formatAccountName = (account: Account) => {
const displayName = account.name || "Unnamed Account";
const displayName = account.display_name || account.name || "Unnamed Account";
return `${displayName} (${account.institution_id})`;
};
@@ -89,7 +89,7 @@ export function AccountCombobox({
{accounts.map((account) => (
<CommandItem
key={account.id}
value={`${account.name} ${account.institution_id}`}
value={`${account.display_name || account.name} ${account.institution_id}`}
onSelect={() => {
onAccountChange(account.id);
setOpen(false);
@@ -105,7 +105,7 @@ export function AccountCombobox({
/>
<div className="flex flex-col">
<span className="font-medium">
{account.name || "Unnamed Account"}
{account.display_name || account.name || "Unnamed Account"}
</span>
<span className="text-xs text-gray-500">
{account.institution_id}

View File

@@ -41,8 +41,8 @@ export const apiClient = {
updateAccount: async (
id: string,
updates: AccountUpdate,
): Promise<{ id: string; name?: string }> => {
const response = await api.put<ApiResponse<{ id: string; name?: string }>>(
): Promise<{ id: string; display_name?: string }> => {
const response = await api.put<ApiResponse<{ id: string; display_name?: string }>>(
`/accounts/${id}`,
updates,
);

View File

@@ -11,6 +11,7 @@ export interface Account {
status: string;
iban?: string;
name?: string;
display_name?: string;
currency?: string;
created: string;
last_accessed?: string;
@@ -18,7 +19,7 @@ export interface Account {
}
export interface AccountUpdate {
name?: string;
display_name?: string;
}
export interface RawTransactionData {

View File

@@ -24,6 +24,7 @@ class AccountDetails(BaseModel):
status: str
iban: Optional[str] = None
name: Optional[str] = None
display_name: Optional[str] = None
currency: Optional[str] = None
created: datetime
last_accessed: Optional[datetime] = None
@@ -36,7 +37,7 @@ class AccountDetails(BaseModel):
class AccountUpdate(BaseModel):
"""Account update model"""
name: Optional[str] = None
display_name: Optional[str] = None
class Config:
json_encoders = {datetime: lambda v: v.isoformat() if v else None}

View File

@@ -53,6 +53,7 @@ async def get_all_accounts() -> APIResponse:
status=db_account["status"],
iban=db_account.get("iban"),
name=db_account.get("name"),
display_name=db_account.get("display_name"),
currency=db_account.get("currency"),
created=db_account["created"],
last_accessed=db_account.get("last_accessed"),
@@ -112,6 +113,7 @@ async def get_account_details(account_id: str) -> APIResponse:
status=db_account["status"],
iban=db_account.get("iban"),
name=db_account.get("name"),
display_name=db_account.get("display_name"),
currency=db_account.get("currency"),
created=db_account["created"],
last_accessed=db_account.get("last_accessed"),
@@ -324,7 +326,7 @@ async def get_account_transactions(
async def update_account_details(
account_id: str, update_data: AccountUpdate
) -> APIResponse:
"""Update account details (currently only name)"""
"""Update account details (currently only display_name)"""
try:
# Get current account details
current_account = await database_service.get_account_details_from_db(account_id)
@@ -336,16 +338,16 @@ async def update_account_details(
# Prepare updated account data
updated_account_data = current_account.copy()
if update_data.name is not None:
updated_account_data["name"] = update_data.name
if update_data.display_name is not None:
updated_account_data["display_name"] = update_data.display_name
# Persist updated account details
await database_service.persist_account_details(updated_account_data)
return APIResponse(
success=True,
data={"id": account_id, "name": update_data.name},
message=f"Account {account_id} name updated successfully",
data={"id": account_id, "display_name": update_data.display_name},
message=f"Account {account_id} display name updated successfully",
)
except HTTPException:

View File

@@ -215,6 +215,7 @@ class DatabaseService:
await self._migrate_balance_timestamps_if_needed()
await self._migrate_null_transaction_ids_if_needed()
await self._migrate_to_composite_key_if_needed()
await self._migrate_add_display_name_if_needed()
async def _migrate_balance_timestamps_if_needed(self):
"""Check and migrate balance timestamps if needed"""
@@ -632,6 +633,79 @@ class DatabaseService:
logger.error(f"Composite key migration failed: {e}")
raise
async def _migrate_add_display_name_if_needed(self):
"""Check and add display_name column to accounts table if needed"""
try:
if await self._check_display_name_migration_needed():
logger.info("Display name column migration needed, starting...")
await self._migrate_add_display_name()
logger.info("Display name column migration completed")
else:
logger.info("Display name column already exists")
except Exception as e:
logger.error(f"Display name column migration failed: {e}")
raise
async def _check_display_name_migration_needed(self) -> bool:
"""Check if display_name column needs to be added to accounts table"""
db_path = path_manager.get_database_path()
if not db_path.exists():
return False
try:
conn = sqlite3.connect(str(db_path))
cursor = conn.cursor()
# Check if accounts table exists
cursor.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='accounts'"
)
if not cursor.fetchone():
conn.close()
return False
# Check if display_name column exists
cursor.execute("PRAGMA table_info(accounts)")
columns = cursor.fetchall()
# Check if display_name column exists
has_display_name = any(col[1] == "display_name" for col in columns)
conn.close()
return not has_display_name
except Exception as e:
logger.error(f"Failed to check display_name migration status: {e}")
return False
async def _migrate_add_display_name(self):
"""Add display_name column to accounts table"""
db_path = path_manager.get_database_path()
if not db_path.exists():
logger.warning("Database file not found, skipping migration")
return
try:
conn = sqlite3.connect(str(db_path))
cursor = conn.cursor()
logger.info("Adding display_name column to accounts table...")
# Add the display_name column
cursor.execute("""
ALTER TABLE accounts
ADD COLUMN display_name TEXT
""")
conn.commit()
conn.close()
logger.info("Display name column migration completed successfully")
except Exception as e:
logger.error(f"Display name column migration failed: {e}")
raise
def _unix_to_datetime_string(self, unix_timestamp: float) -> str:
"""Convert Unix timestamp to datetime string"""
dt = datetime.fromtimestamp(unix_timestamp)
@@ -1045,7 +1119,8 @@ class DatabaseService:
currency TEXT,
created DATETIME,
last_accessed DATETIME,
last_updated DATETIME
last_updated DATETIME,
display_name TEXT
)"""
)
@@ -1060,6 +1135,16 @@ class DatabaseService:
)
try:
# First, check if account exists and preserve display_name
cursor.execute(
"SELECT display_name FROM accounts WHERE id = ?", (account_data["id"],)
)
existing_row = cursor.fetchone()
existing_display_name = existing_row[0] if existing_row else None
# Use existing display_name if not provided in account_data
display_name = account_data.get("display_name", existing_display_name)
# Insert or replace account data
cursor.execute(
"""INSERT OR REPLACE INTO accounts (
@@ -1071,8 +1156,9 @@ class DatabaseService:
currency,
created,
last_accessed,
last_updated
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
last_updated,
display_name
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
account_data["id"],
account_data["institution_id"],
@@ -1083,6 +1169,7 @@ class DatabaseService:
account_data["created"],
account_data.get("last_accessed"),
account_data.get("last_updated", account_data["created"]),
display_name,
),
)
conn.commit()

View File

@@ -1,6 +1,6 @@
[project]
name = "leggen"
version = "2025.9.11"
version = "2025.9.12"
description = "An Open Banking CLI"
authors = [{ name = "Elisiário Couto", email = "elisiario@couto.io" }]
requires-python = "~=3.13.0"

View File

@@ -106,7 +106,8 @@ class SampleDataGenerator:
currency TEXT,
created DATETIME,
last_accessed DATETIME,
last_updated DATETIME
last_updated DATETIME,
display_name TEXT
)
""")
@@ -373,8 +374,8 @@ class SampleDataGenerator:
cursor.execute(
"""
INSERT OR REPLACE INTO accounts
(id, institution_id, status, iban, name, currency, created, last_accessed, last_updated)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
(id, institution_id, status, iban, name, currency, created, last_accessed, last_updated, display_name)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
account["id"],
@@ -386,6 +387,7 @@ class SampleDataGenerator:
account["created"],
account["last_accessed"],
account["last_updated"],
None, # display_name is initially None for sample data
),
)

View File

@@ -24,6 +24,8 @@ class TestAccountsAPI:
"institution_id": "REVOLUT_REVOLT21",
"status": "READY",
"iban": "LT313250081177977789",
"name": "Personal Account",
"display_name": None,
"created": "2024-02-13T23:56:00Z",
"last_accessed": "2025-09-01T09:30:00Z",
}
@@ -80,6 +82,8 @@ class TestAccountsAPI:
"institution_id": "REVOLUT_REVOLT21",
"status": "READY",
"iban": "LT313250081177977789",
"name": "Personal Account",
"display_name": None,
"created": "2024-02-13T23:56:00Z",
"last_accessed": "2025-09-01T09:30:00Z",
}
@@ -283,3 +287,58 @@ class TestAccountsAPI:
response = api_client.get("/api/v1/accounts/nonexistent")
assert response.status_code == 404
def test_update_account_display_name_success(
self, api_client, mock_config, mock_auth_token, mock_db_path
):
"""Test successful update of account display name."""
mock_account = {
"id": "test-account-123",
"institution_id": "REVOLUT_REVOLT21",
"status": "READY",
"iban": "LT313250081177977789",
"name": "Personal Account",
"display_name": None,
"created": "2024-02-13T23:56:00Z",
"last_accessed": "2025-09-01T09:30:00Z",
}
with (
patch("leggen.utils.config.config", mock_config),
patch(
"leggen.api.routes.accounts.database_service.get_account_details_from_db",
return_value=mock_account,
),
patch(
"leggen.api.routes.accounts.database_service.persist_account_details",
return_value=None,
),
):
response = api_client.put(
"/api/v1/accounts/test-account-123",
json={"display_name": "My Custom Account Name"},
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["data"]["id"] == "test-account-123"
assert data["data"]["display_name"] == "My Custom Account Name"
def test_update_account_not_found(
self, api_client, mock_config, mock_auth_token, mock_db_path
):
"""Test updating non-existent account."""
with (
patch("leggen.utils.config.config", mock_config),
patch(
"leggen.api.routes.accounts.database_service.get_account_details_from_db",
return_value=None,
),
):
response = api_client.put(
"/api/v1/accounts/nonexistent",
json={"display_name": "New Name"},
)
assert response.status_code == 404

View File

@@ -0,0 +1,192 @@
"""Tests for running balance calculation logic in frontend.
This test validates the running balance calculation algorithm to ensure
it correctly computes account balances after each transaction.
"""
import pytest
class TestRunningBalanceCalculation:
"""Test running balance calculation logic."""
def test_running_balance_calculation_logic(self):
"""Test the logic behind running balance calculation.
This test validates the algorithm used in the frontend TransactionsTable
component to ensure running balances are calculated correctly.
"""
# Sample test data similar to what the frontend receives
transactions = [
{
"transaction_id": "txn-1",
"account_id": "account-001",
"transaction_value": -100.00,
"transaction_date": "2025-01-01T10:00:00Z",
"description": "Initial expense"
},
{
"transaction_id": "txn-2",
"account_id": "account-001",
"transaction_value": 500.00,
"transaction_date": "2025-01-02T10:00:00Z",
"description": "Salary"
},
{
"transaction_id": "txn-3",
"account_id": "account-001",
"transaction_value": -50.00,
"transaction_date": "2025-01-03T10:00:00Z",
"description": "Shopping"
}
]
balances = [
{
"account_id": "account-001",
"balance_amount": 350.00, # Final balance after all transactions
"balance_type": "interimAvailable"
}
]
# Implement the corrected algorithm from the frontend
running_balances = self._calculate_running_balances(transactions, balances)
# Expected running balances:
# - After txn-1 (-100): balance should be -100.00
# - After txn-2 (+500): balance should be 400.00 (previous + 500)
# - After txn-3 (-50): balance should be 350.00 (previous - 50)
assert running_balances["account-001-txn-1"] == -100.00, "First transaction running balance incorrect"
assert running_balances["account-001-txn-2"] == 400.00, "Second transaction running balance incorrect"
assert running_balances["account-001-txn-3"] == 350.00, "Third transaction running balance incorrect"
# Final balance should match current account balance
final_calculated_balance = running_balances["account-001-txn-3"]
assert final_calculated_balance == balances[0]["balance_amount"], "Final balance doesn't match current account balance"
def test_running_balance_multiple_accounts(self):
"""Test running balance calculation with multiple accounts."""
transactions = [
{
"transaction_id": "txn-1",
"account_id": "account-001",
"transaction_value": -50.00,
"transaction_date": "2025-01-01T10:00:00Z",
"description": "Account 1 expense"
},
{
"transaction_id": "txn-2",
"account_id": "account-002",
"transaction_value": -25.00,
"transaction_date": "2025-01-01T11:00:00Z",
"description": "Account 2 expense"
},
{
"transaction_id": "txn-3",
"account_id": "account-001",
"transaction_value": 100.00,
"transaction_date": "2025-01-02T10:00:00Z",
"description": "Account 1 income"
}
]
balances = [
{
"account_id": "account-001",
"balance_amount": 50.00,
"balance_type": "interimAvailable"
},
{
"account_id": "account-002",
"balance_amount": 75.00,
"balance_type": "interimAvailable"
}
]
running_balances = self._calculate_running_balances(transactions, balances)
# Account 1: starts at 0, -50 = -50, +100 = 50
assert running_balances["account-001-txn-1"] == -50.00
assert running_balances["account-001-txn-3"] == 50.00
# Account 2: starts at 100, -25 = 75
assert running_balances["account-002-txn-2"] == 75.00
def test_running_balance_empty_transactions(self):
"""Test running balance calculation with no transactions."""
transactions = []
balances = [
{
"account_id": "account-001",
"balance_amount": 100.00,
"balance_type": "interimAvailable"
}
]
running_balances = self._calculate_running_balances(transactions, balances)
assert running_balances == {}
def test_running_balance_no_balances(self):
"""Test running balance calculation with no balance data."""
transactions = [
{
"transaction_id": "txn-1",
"account_id": "account-001",
"transaction_value": -50.00,
"transaction_date": "2025-01-01T10:00:00Z",
"description": "Expense"
}
]
balances = []
running_balances = self._calculate_running_balances(transactions, balances)
# When no balance data available, current balance is 0
# Working backwards: starting_balance = 0 - (-50) = 50
# Going forward: running_balance = 50 + (-50) = 0
assert running_balances["account-001-txn-1"] == 0.00
def _calculate_running_balances(self, transactions, balances):
"""
Implementation of the corrected running balance calculation algorithm.
This mirrors the logic implemented in the frontend TransactionsTable component.
"""
running_balances = {}
account_balance_map = {}
# Create a map of account current balances - use interimAvailable as the most current
for balance in balances:
if balance["balance_type"] == "interimAvailable":
account_balance_map[balance["account_id"]] = balance["balance_amount"]
# Group transactions by account
transactions_by_account = {}
for txn in transactions:
account_id = txn["account_id"]
if account_id not in transactions_by_account:
transactions_by_account[account_id] = []
transactions_by_account[account_id].append(txn)
# Calculate running balance for each account
for account_id, account_transactions in transactions_by_account.items():
current_balance = account_balance_map.get(account_id, 0)
# Sort transactions by date (oldest first) for forward calculation
sorted_transactions = sorted(
account_transactions,
key=lambda x: x["transaction_date"]
)
# Calculate the starting balance by working backwards from current balance
starting_balance = current_balance
for txn in reversed(sorted_transactions):
starting_balance -= txn["transaction_value"]
# Now calculate running balances going forward chronologically
running_balance = starting_balance
for txn in sorted_transactions:
running_balance += txn["transaction_value"]
running_balances[f"{txn['account_id']}-{txn['transaction_id']}"] = running_balance
return running_balances

2
uv.lock generated
View File

@@ -220,7 +220,7 @@ wheels = [
[[package]]
name = "leggen"
version = "2025.9.11"
version = "2025.9.12"
source = { editable = "." }
dependencies = [
{ name = "apscheduler" },