libra/account_sync.py
Padreug 4d63e08a69 fix(fava): serialize source mutations, share HTTP client, validate BQL input
Fava-client hardening cluster (CODE-REVIEW-2026-06 #7, #14, #15, #19
+ libra-#23, libra-#53):

- New FavaClient.transform_source_line does the whole
  read-checksum-modify-write under the global write lock and maps
  Fava 409/412 to ChecksumConflictError. The approve and reject
  endpoints used to do this dance with raw httpx and no lock — two
  concurrent mutations raced each other and every other ledger
  writer (libra-#23). They now route through the new method and
  translate conflicts to HTTP 409.
- update_entry_source / delete_entry raise ChecksumConflictError on
  409/412 instead of leaking raw HTTPStatusError.
- One shared httpx.AsyncClient per FavaClient (12 per-call
  instantiations removed — no more TCP handshake per request);
  closed via libra_stop. Health probes keep their 2s timeout
  per-request.
- Account names/patterns are validated against ^[A-Za-z0-9:_-]+$
  before interpolation into BQL string literals.
- The posting amount regexes are consolidated into module-level
  compiled patterns, all decimal-tolerant — the old integer-only
  SATS pattern silently dropped decimal-SATS postings (Fava's @@->@
  normalisation emits them) from balances.
- add-account no longer verifies its own write with a second
  serialized get_all_accounts round-trip (libra-#53):
  sync_single_account_from_beancount grows an assume_exists path.

New test: concurrent approve+reject must both land (was
lost-update/412 before the lock).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 15:42:44 +02:00

441 lines
15 KiB
Python

"""
Account Synchronization Module
Syncs accounts from Beancount (source of truth) to Libra DB (metadata store).
This implements the hybrid approach:
- Beancount owns account existence (Open directives)
- Libra DB stores permissions and user associations
- Background sync keeps them in sync
Related: ACCOUNTS-TABLE-REMOVAL-FEASIBILITY.md - Phase 2 implementation
"""
from datetime import datetime
from typing import Optional
from loguru import logger
from .crud import (
create_account,
get_account_by_name,
get_all_accounts,
update_account_is_active,
)
from .fava_client import get_fava_client
from .models import AccountType, CreateAccount
def infer_account_type_from_name(account_name: str) -> AccountType:
"""
Infer Beancount account type from hierarchical name.
Args:
account_name: Hierarchical account name (e.g., "Expenses:Food:Groceries")
Returns:
AccountType enum value
Examples:
"Assets:Cash" → AccountType.ASSET
"Liabilities:PayPal" → AccountType.LIABILITY
"Expenses:Food" → AccountType.EXPENSE
"Income:Services" → AccountType.REVENUE
"Equity:Opening-Balances" → AccountType.EQUITY
"""
root = account_name.split(":")[0]
type_map = {
"Assets": AccountType.ASSET,
"Liabilities": AccountType.LIABILITY,
"Expenses": AccountType.EXPENSE,
"Income": AccountType.REVENUE,
"Equity": AccountType.EQUITY,
}
# Default to ASSET if unknown (shouldn't happen with valid Beancount)
return type_map.get(root, AccountType.ASSET)
def extract_user_id_from_account_name(account_name: str) -> Optional[str]:
"""
Extract user ID from account name if it's a user-specific account.
Args:
account_name: Hierarchical account name
Returns:
User ID if found, None otherwise
Examples:
"Assets:Receivable:User-abc123def""abc123def456ghi789"
"Liabilities:Payable:User-abc123""abc123def456ghi789"
"Expenses:Food" → None
"""
if ":User-" not in account_name:
return None
# Extract the part after "User-"
parts = account_name.split(":User-")
if len(parts) < 2:
return None
# First 8 characters are the user ID prefix
user_id_prefix = parts[1]
# For now, return the prefix (could look up full user ID from DB if needed)
# Note: get_or_create_user_account() uses 8-char prefix in account names
return user_id_prefix
async def sync_accounts_from_beancount(force_full_sync: bool = False) -> dict:
"""
Sync accounts from Beancount to Libra DB.
This ensures Libra DB has metadata entries for all accounts that exist
in Beancount, enabling permissions and user associations to work properly.
New behavior (soft delete + virtual parents):
- Accounts in Beancount but not in Libra DB: Added as active
- Accounts in Libra DB but not in Beancount: Marked as inactive (soft delete)
- Inactive accounts that return to Beancount: Reactivated
- Missing intermediate parents: Auto-created as virtual accounts
Virtual parent auto-generation example:
Beancount has: "Expenses:Supplies:Food"
Missing parent: "Expenses:Supplies" (doesn't exist in Beancount)
→ Auto-create "Expenses:Supplies" as virtual account
→ Enables granting permission on "Expenses:Supplies" to cover all Supplies:* children
Args:
force_full_sync: If True, re-check all accounts. If False, only add new ones.
Returns:
dict with sync statistics:
{
"total_beancount_accounts": 150,
"total_libra_accounts": 148,
"accounts_added": 2,
"accounts_updated": 0,
"accounts_skipped": 148,
"accounts_deactivated": 5,
"accounts_reactivated": 1,
"virtual_parents_created": 3,
"errors": []
}
"""
logger.info("Starting account sync from Beancount to Libra DB")
fava = get_fava_client()
# Get all accounts from Beancount
try:
beancount_accounts = await fava.get_all_accounts()
except Exception as e:
logger.error(f"Failed to fetch accounts from Beancount: {e}")
return {
"total_beancount_accounts": 0,
"total_libra_accounts": 0,
"accounts_added": 0,
"accounts_updated": 0,
"accounts_skipped": 0,
"accounts_deactivated": 0,
"accounts_reactivated": 0,
"errors": [str(e)],
}
# Get all accounts from Libra DB (including inactive ones for sync)
libra_accounts = await get_all_accounts(include_inactive=True)
# Build lookup maps
beancount_account_names = {acc["account"] for acc in beancount_accounts}
libra_accounts_by_name = {acc.name: acc for acc in libra_accounts}
stats = {
"total_beancount_accounts": len(beancount_accounts),
"total_libra_accounts": len(libra_accounts),
"accounts_added": 0,
"accounts_updated": 0,
"accounts_skipped": 0,
"accounts_deactivated": 0,
"accounts_reactivated": 0,
"virtual_parents_created": 0,
"errors": [],
}
# Step 1: Sync accounts from Beancount to Libra DB
for bc_account in beancount_accounts:
account_name = bc_account["account"]
try:
existing = libra_accounts_by_name.get(account_name)
if existing:
# Account exists in Libra DB
# Check if it needs to be reactivated
if not existing.is_active:
await update_account_is_active(existing.id, True)
stats["accounts_reactivated"] += 1
logger.info(f"Reactivated account: {account_name}")
else:
stats["accounts_skipped"] += 1
logger.debug(f"Account already active: {account_name}")
continue
# Create new account in Libra DB
account_type = infer_account_type_from_name(account_name)
user_id = extract_user_id_from_account_name(account_name)
# Get description from Beancount metadata if available
description = None
if "meta" in bc_account and isinstance(bc_account["meta"], dict):
description = bc_account["meta"].get("description")
await create_account(
CreateAccount(
name=account_name,
account_type=account_type,
description=description,
user_id=user_id,
)
)
stats["accounts_added"] += 1
logger.info(f"Added account from Beancount: {account_name}")
except Exception as e:
error_msg = f"Failed to sync account {account_name}: {e}"
logger.error(error_msg)
stats["errors"].append(error_msg)
# Step 2: Mark orphaned accounts (in Libra DB but not in Beancount) as inactive
# SKIP virtual accounts (they're intentionally metadata-only)
for libra_account in libra_accounts:
if libra_account.is_virtual:
# Virtual accounts are metadata-only, never deactivate them
continue
if libra_account.name not in beancount_account_names:
# Account no longer exists in Beancount
if libra_account.is_active:
try:
await update_account_is_active(libra_account.id, False)
stats["accounts_deactivated"] += 1
logger.info(
f"Deactivated orphaned account: {libra_account.name}"
)
except Exception as e:
error_msg = (
f"Failed to deactivate account {libra_account.name}: {e}"
)
logger.error(error_msg)
stats["errors"].append(error_msg)
# Step 3: Auto-generate virtual intermediate parent accounts
# For each account in Beancount, check if all parent levels exist
# If not, create them as virtual accounts
# IMPORTANT: Re-fetch accounts from DB after Step 1 added new accounts
# Otherwise we'll be checking against stale data and miss newly synced children
current_libra_accounts = await get_all_accounts(include_inactive=True)
all_account_names = {acc.name for acc in current_libra_accounts}
for bc_account in beancount_accounts:
account_name = bc_account["account"]
parts = account_name.split(":")
# Check each parent level (e.g., for "Expenses:Supplies:Food", check "Expenses:Supplies")
for i in range(1, len(parts)):
parent_name = ":".join(parts[:i])
# Skip if parent already exists
if parent_name in all_account_names:
continue
# Create virtual parent account
try:
parent_type = infer_account_type_from_name(parent_name)
await create_account(
CreateAccount(
name=parent_name,
account_type=parent_type,
description=f"Auto-generated virtual parent for {parent_name}:* accounts",
is_virtual=True,
)
)
stats["virtual_parents_created"] += 1
all_account_names.add(parent_name) # Track so we don't create duplicates
logger.info(f"Created virtual parent account: {parent_name}")
except Exception as e:
error_msg = f"Failed to create virtual parent {parent_name}: {e}"
logger.error(error_msg)
stats["errors"].append(error_msg)
logger.info(
f"Account sync complete: "
f"{stats['accounts_added']} added, "
f"{stats['accounts_reactivated']} reactivated, "
f"{stats['accounts_deactivated']} deactivated, "
f"{stats['virtual_parents_created']} virtual parents created, "
f"{stats['accounts_skipped']} skipped, "
f"{len(stats['errors'])} errors"
)
return stats
async def sync_single_account_from_beancount(
account_name: str,
description: Optional[str] = None,
assume_exists: bool = False,
) -> bool:
"""
Sync a single account from Beancount to Libra DB.
Useful for ensuring a specific account exists in Libra DB before
granting permissions on it.
Args:
account_name: Hierarchical account name (e.g., "Expenses:Food")
description: Description for the Libra DB row (only used with
assume_exists — otherwise read from Beancount metadata)
assume_exists: Skip the Fava existence lookup. Pass when the
caller just wrote the Open directive itself — verifying via
a second serialized get_all_accounts round-trip doubles the
latency of every account create for no information gain
(libra-#53).
Returns:
True if account was created/updated, False if it already existed or failed
"""
logger.debug(f"Syncing single account: {account_name}")
# Check if already exists
existing = await get_account_by_name(account_name)
if existing:
logger.debug(f"Account already exists: {account_name}")
return False
if assume_exists:
try:
await create_account(
CreateAccount(
name=account_name,
account_type=infer_account_type_from_name(account_name),
description=description,
user_id=extract_user_id_from_account_name(account_name),
)
)
logger.info(f"Created account (writer-asserted): {account_name}")
return True
except Exception as e:
logger.error(f"Failed to sync account {account_name}: {e}")
return False
# Get from Beancount
fava = get_fava_client()
try:
all_accounts = await fava.get_all_accounts()
bc_account = next(
(acc for acc in all_accounts if acc["account"] == account_name), None
)
if not bc_account:
logger.error(f"Account not found in Beancount: {account_name}")
return False
# Create in Libra DB
account_type = infer_account_type_from_name(account_name)
# Prefer the full user_id stored in Beancount metadata (libra writes it
# when crud.get_or_create_user_account calls fava.add_account). Fall
# back to the name-derived 8-char prefix for accounts imported without
# metadata. This keeps user_id consistent with what the caller will
# query for, avoiding a churn cycle through the UNIQUE-constraint
# recovery path in crud.py.
description = None
meta_user_id = None
if "meta" in bc_account and isinstance(bc_account["meta"], dict):
description = bc_account["meta"].get("description")
meta_user_id = bc_account["meta"].get("user_id")
user_id = meta_user_id or extract_user_id_from_account_name(account_name)
await create_account(
CreateAccount(
name=account_name,
account_type=account_type,
description=description,
user_id=user_id,
)
)
logger.info(f"Created account from Beancount: {account_name}")
return True
except Exception as e:
logger.error(f"Failed to sync account {account_name}: {e}")
return False
async def ensure_account_exists_in_libra(account_name: str) -> bool:
"""
Ensure account exists in Libra DB, creating from Beancount if needed.
This is the recommended function to call before granting permissions.
Args:
account_name: Hierarchical account name
Returns:
True if account exists (or was created), False if failed
"""
# Check Libra DB first
existing = await get_account_by_name(account_name)
if existing:
return True
# Try to sync from Beancount
return await sync_single_account_from_beancount(account_name)
# Background sync task (can be scheduled with cron or async scheduler)
async def scheduled_account_sync():
"""
Scheduled task to sync accounts from Beancount to Libra DB.
Run this periodically (e.g., every hour) to keep Libra DB in sync with Beancount.
Example with APScheduler:
from apscheduler.schedulers.asyncio import AsyncIOScheduler
scheduler = AsyncIOScheduler()
scheduler.add_job(
scheduled_account_sync,
'interval',
hours=1, # Run every hour
id='account_sync'
)
scheduler.start()
"""
logger.info("Running scheduled account sync")
try:
stats = await sync_accounts_from_beancount(force_full_sync=False)
if stats["accounts_added"] > 0:
logger.info(
f"Scheduled sync: Added {stats['accounts_added']} new accounts"
)
if stats["errors"]:
logger.warning(
f"Scheduled sync: {len(stats['errors'])} errors encountered"
)
return stats
except Exception as e:
logger.error(f"Scheduled account sync failed: {e}")
raise