mirror of
https://github.com/elisiariocouto/leggen.git
synced 2025-12-29 07:29:03 +00:00
Compare commits
7 Commits
2025.9.11
...
4c8056858c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c8056858c | ||
|
|
1cfc5f0422 | ||
|
|
bfb5a7ef76 | ||
|
|
95b3b93a8a | ||
|
|
9a2199873c | ||
|
|
82a12dadad | ||
|
|
33a7ad5ad2 |
@@ -1,4 +1,10 @@
|
||||
|
||||
## 2025.9.12 (2025/09/15)
|
||||
|
||||
|
||||
## 2025.9.12 (2025/09/15)
|
||||
|
||||
|
||||
## 2025.9.11 (2025/09/15)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
192
tests/unit/test_running_balance_calculation.py
Normal file
192
tests/unit/test_running_balance_calculation.py
Normal 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
|
||||
Reference in New Issue
Block a user