feat: Add equity account support to transaction filtering and Beancount import
Improvements to equity account handling across the Castle extension: Transaction Categorization (views_api.py): - Prioritize equity accounts when enriching transaction entries - Use two-pass lookup: first search for equity accounts, then fall back to liability/asset accounts - Ensures transactions with Equity:User-<id> accounts are correctly categorized as equity UI Enhancements (index.html, index.js): - Add 'Equity' filter option to Recent Transactions table - Display blue "Equity" badge for equity entries (before receivable/payable badges) - Add isEquity() helper function to identify equity account entries Beancount Import (import_beancount.py): - Support importing Beancount Equity:<name> accounts - Map Beancount "Equity:Pat" to Castle "Equity:User-<id>" accounts - Update extract_user_from_user_account() to handle Equity: prefix - Improve error messages to include equity account examples - Add equity account lookup in get_account_id() with helpful error if equity not enabled These changes ensure equity accounts (representing user capital contributions) are properly distinguished from payables and receivables throughout the system.
This commit is contained in:
parent
6f1fa7203b
commit
0b64ffa54f
4 changed files with 74 additions and 11 deletions
|
|
@ -153,9 +153,10 @@ class AccountLookup:
|
||||||
Special handling for user-specific accounts:
|
Special handling for user-specific accounts:
|
||||||
- "Liabilities:Payable:Pat" -> looks up Pat's user_id and finds their Castle payable account
|
- "Liabilities:Payable:Pat" -> looks up Pat's user_id and finds their Castle payable account
|
||||||
- "Assets:Receivable:Pat" -> looks up Pat's user_id and finds their Castle receivable account
|
- "Assets:Receivable:Pat" -> looks up Pat's user_id and finds their Castle receivable account
|
||||||
|
- "Equity:Pat" -> looks up Pat's user_id and finds their Castle equity account
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
account_name: Beancount account name (e.g., "Expenses:Food:Supplies", "Liabilities:Payable:Pat", "Assets:Receivable:Pat")
|
account_name: Beancount account name (e.g., "Expenses:Food:Supplies", "Liabilities:Payable:Pat", "Assets:Receivable:Pat", "Equity:Pat")
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Castle account UUID or None if not found
|
Castle account UUID or None if not found
|
||||||
|
|
@ -204,6 +205,28 @@ class AccountLookup:
|
||||||
f"Please configure the wallet for user ID: {user_id}"
|
f"Please configure the wallet for user ID: {user_id}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Check if this is an Equity:<name> account
|
||||||
|
# Map Beancount Equity:Pat to Castle Equity:User-<id>
|
||||||
|
elif account_name.startswith("Equity:"):
|
||||||
|
user_name = extract_user_from_user_account(account_name)
|
||||||
|
if user_name:
|
||||||
|
# Look up user's actual user_id
|
||||||
|
user_id = USER_MAPPINGS.get(user_name)
|
||||||
|
if user_id:
|
||||||
|
# Find this user's equity account
|
||||||
|
# This is the Equity:User-<id> account in Castle
|
||||||
|
if user_id in self.accounts_by_user:
|
||||||
|
equity_account_id = self.accounts_by_user[user_id].get('equity')
|
||||||
|
if equity_account_id:
|
||||||
|
return equity_account_id
|
||||||
|
|
||||||
|
# If not found, provide helpful error
|
||||||
|
raise ValueError(
|
||||||
|
f"User '{user_name}' (ID: {user_id}) does not have an equity account.\n"
|
||||||
|
f"Equity eligibility must be enabled for this user in Castle.\n"
|
||||||
|
f"Please enable equity for user ID: {user_id}"
|
||||||
|
)
|
||||||
|
|
||||||
# Normal account lookup by name
|
# Normal account lookup by name
|
||||||
return self.accounts.get(account_name)
|
return self.accounts.get(account_name)
|
||||||
|
|
||||||
|
|
@ -332,11 +355,12 @@ def parse_beancount_transaction(txn_text: str) -> Optional[Dict]:
|
||||||
|
|
||||||
def extract_user_from_user_account(account_name: str) -> Optional[str]:
|
def extract_user_from_user_account(account_name: str) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
Extract user name from user-specific accounts (Payable or Receivable).
|
Extract user name from user-specific accounts (Payable, Receivable, or Equity).
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
"Liabilities:Payable:Pat" -> "Pat"
|
"Liabilities:Payable:Pat" -> "Pat"
|
||||||
"Assets:Receivable:Alice" -> "Alice"
|
"Assets:Receivable:Alice" -> "Alice"
|
||||||
|
"Equity:Pat" -> "Pat"
|
||||||
"Expenses:Food" -> None
|
"Expenses:Food" -> None
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
|
|
@ -350,6 +374,10 @@ def extract_user_from_user_account(account_name: str) -> Optional[str]:
|
||||||
parts = account_name.split(":")
|
parts = account_name.split(":")
|
||||||
if len(parts) >= 3:
|
if len(parts) >= 3:
|
||||||
return parts[2]
|
return parts[2]
|
||||||
|
elif account_name.startswith("Equity:"):
|
||||||
|
parts = account_name.split(":")
|
||||||
|
if len(parts) >= 2:
|
||||||
|
return parts[1]
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def determine_user_id(postings: list) -> Optional[str]:
|
def determine_user_id(postings: list) -> Optional[str]:
|
||||||
|
|
@ -386,8 +414,11 @@ def convert_to_castle_entry(parsed: dict, btc_eur_rate: float, account_lookup: A
|
||||||
if not user_id:
|
if not user_id:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Could not determine user ID for transaction.\n"
|
f"Could not determine user ID for transaction.\n"
|
||||||
f"Transactions must have a Liabilities:Payable:<name> or Assets:Receivable:<name> account.\n"
|
f"Transactions must have a user-specific account:\n"
|
||||||
f"Examples: Liabilities:Payable:Pat, Assets:Receivable:Pat"
|
f" - Liabilities:Payable:<name> (for payables)\n"
|
||||||
|
f" - Assets:Receivable:<name> (for receivables)\n"
|
||||||
|
f" - Equity:<name> (for equity)\n"
|
||||||
|
f"Examples: Liabilities:Payable:Pat, Assets:Receivable:Pat, Equity:Pat"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Build entry lines
|
# Build entry lines
|
||||||
|
|
|
||||||
|
|
@ -202,7 +202,8 @@ window.app = Vue.createApp({
|
||||||
return [
|
return [
|
||||||
{ label: 'All Types', value: null },
|
{ label: 'All Types', value: null },
|
||||||
{ label: 'Receivable (User owes Castle)', value: 'asset' },
|
{ label: 'Receivable (User owes Castle)', value: 'asset' },
|
||||||
{ label: 'Payable (Castle owes User)', value: 'liability' }
|
{ label: 'Payable (Castle owes User)', value: 'liability' },
|
||||||
|
{ label: 'Equity (User Balance)', value: 'equity' }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
expenseAccounts() {
|
expenseAccounts() {
|
||||||
|
|
@ -1551,6 +1552,19 @@ window.app = Vue.createApp({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
|
},
|
||||||
|
isEquity(entry) {
|
||||||
|
// Check if this is an equity entry (user capital contribution/balance)
|
||||||
|
if (!entry.lines || entry.lines.length === 0) return false
|
||||||
|
|
||||||
|
for (const line of entry.lines) {
|
||||||
|
// Check if the account is an equity account
|
||||||
|
const account = this.accounts.find(a => a.id === line.account_id)
|
||||||
|
if (account && account.account_type === 'equity') {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async created() {
|
async created() {
|
||||||
|
|
|
||||||
|
|
@ -427,7 +427,10 @@
|
||||||
<q-td :props="props">
|
<q-td :props="props">
|
||||||
<div>
|
<div>
|
||||||
{% raw %}{{ props.row.description }}{% endraw %}
|
{% raw %}{{ props.row.description }}{% endraw %}
|
||||||
<q-badge v-if="isSuperUser && isReceivable(props.row)" color="positive" class="q-ml-sm">
|
<q-badge v-if="isSuperUser && isEquity(props.row)" color="blue" class="q-ml-sm">
|
||||||
|
Equity
|
||||||
|
</q-badge>
|
||||||
|
<q-badge v-else-if="isSuperUser && isReceivable(props.row)" color="positive" class="q-ml-sm">
|
||||||
Receivable
|
Receivable
|
||||||
</q-badge>
|
</q-badge>
|
||||||
<q-badge v-else-if="isSuperUser && isPayable(props.row)" color="negative" class="q-ml-sm">
|
<q-badge v-else-if="isSuperUser && isPayable(props.row)" color="negative" class="q-ml-sm">
|
||||||
|
|
|
||||||
25
views_api.py
25
views_api.py
|
|
@ -318,18 +318,33 @@ async def api_get_user_entries(
|
||||||
enriched_entries = []
|
enriched_entries = []
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
# Find user_id from entry lines (look for user-specific accounts)
|
# Find user_id from entry lines (look for user-specific accounts)
|
||||||
|
# Prioritize equity accounts, then liability/asset accounts
|
||||||
entry_user_id = None
|
entry_user_id = None
|
||||||
entry_username = None
|
entry_username = None
|
||||||
entry_account_type = None
|
entry_account_type = None
|
||||||
|
|
||||||
|
equity_account = None
|
||||||
|
other_user_account = None
|
||||||
|
|
||||||
|
# First pass: look for equity and other user accounts
|
||||||
for line in entry.lines:
|
for line in entry.lines:
|
||||||
account = await get_account(line.account_id)
|
account = await get_account(line.account_id)
|
||||||
if account and account.user_id:
|
if account and account.user_id:
|
||||||
entry_user_id = account.user_id
|
account_type = account.account_type.value if hasattr(account.account_type, 'value') else account.account_type
|
||||||
entry_account_type = account.account_type.value if hasattr(account.account_type, 'value') else account.account_type
|
|
||||||
user = await get_user(account.user_id)
|
if account_type == 'equity':
|
||||||
entry_username = user.username if user and user.username else account.user_id[:16] + "..."
|
equity_account = (account.user_id, account_type, account)
|
||||||
break
|
break # Prioritize equity, stop searching
|
||||||
|
elif not other_user_account:
|
||||||
|
other_user_account = (account.user_id, account_type, account)
|
||||||
|
|
||||||
|
# Use equity account if found, otherwise use other user account
|
||||||
|
selected_account = equity_account or other_user_account
|
||||||
|
|
||||||
|
if selected_account:
|
||||||
|
entry_user_id, entry_account_type, account_obj = selected_account
|
||||||
|
user = await get_user(entry_user_id)
|
||||||
|
entry_username = user.username if user and user.username else entry_user_id[:16] + "..."
|
||||||
|
|
||||||
enriched_entries.append({
|
enriched_entries.append({
|
||||||
**entry.dict(),
|
**entry.dict(),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue