Compare commits

..

No commits in common. "main" and "fix/authorization-security-refactor" have entirely different histories.

8 changed files with 375 additions and 728 deletions

20
crud.py
View file

@ -424,26 +424,6 @@ async def get_user_wallet_settings(user_id: str) -> Optional[UserWalletSettings]
)
async def get_user_wallet_settings_by_prefix(
user_id_prefix: str,
) -> Optional[StoredUserWalletSettings]:
"""
Get user wallet settings by user ID prefix (for truncated 8-char IDs from Beancount).
Beancount accounts use truncated user IDs (first 8 chars), but the database
stores full UUIDs. This function looks up by prefix to bridge the gap.
"""
return await db.fetchone(
"""
SELECT * FROM user_wallet_settings
WHERE id LIKE :prefix || '%'
LIMIT 1
""",
{"prefix": user_id_prefix},
StoredUserWalletSettings,
)
async def update_user_wallet_settings(
user_id: str, data: UserWalletSettings
) -> UserWalletSettings:

View file

@ -17,7 +17,6 @@ Fava provides a REST API for:
See: https://github.com/beancount/fava/blob/main/src/fava/json_api.py
"""
import asyncio
import httpx
from typing import Any, Dict, List, Optional
from decimal import Decimal
@ -25,11 +24,6 @@ from datetime import date, datetime
from loguru import logger
class ChecksumConflictError(Exception):
"""Raised when a Fava write operation fails due to stale checksum (concurrent modification)."""
pass
class FavaClient:
"""
Async client for Fava REST API.
@ -54,37 +48,6 @@ class FavaClient:
self.base_url = f"{self.fava_url}/{self.ledger_slug}/api"
self.timeout = timeout
# Concurrency control: Global write lock to serialize all ledger modifications.
# This prevents race conditions when multiple requests try to write to the
# Beancount ledger file simultaneously. Without this lock, concurrent writes
# can cause data loss, duplicate entries, or file corruption.
#
# Note: This serializes ALL writes which may become a bottleneck at scale.
# For higher throughput, consider per-user locking or distributed locking.
self._write_lock = asyncio.Lock()
# Per-user locks for user-specific operations (reduces contention)
self._user_locks: Dict[str, asyncio.Lock] = {}
def get_user_lock(self, user_id: str) -> asyncio.Lock:
"""
Get or create a lock for a specific user.
This enables per-user locking to reduce contention when multiple users
are making concurrent requests. User-specific operations should acquire
this lock in addition to (or instead of) the global write lock.
Args:
user_id: User ID (uses first 8 characters for consistency)
Returns:
asyncio.Lock for this user
"""
user_key = user_id[:8]
if user_key not in self._user_locks:
self._user_locks[user_key] = asyncio.Lock()
return self._user_locks[user_key]
async def add_entry(self, entry: Dict[str, Any]) -> Dict[str, Any]:
"""
Submit a new journal entry to Fava.
@ -125,100 +88,26 @@ class FavaClient:
"meta": {"user_id": "abc123"}
}
result = await fava_client.add_entry(entry)
Note:
This method acquires a global write lock to prevent concurrent
modifications to the ledger file. All writes are serialized.
"""
# Acquire global write lock to serialize ledger modifications
async with self._write_lock:
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.put(
f"{self.base_url}/add_entries",
json={"entries": [entry]},
headers={"Content-Type": "application/json"}
)
response.raise_for_status()
result = response.json()
logger.info(f"Added entry to Fava: {result.get('data', 'Unknown')}")
return result
except httpx.HTTPStatusError as e:
logger.error(f"Fava HTTP error: {e.response.status_code} - {e.response.text}")
raise
except httpx.RequestError as e:
logger.error(f"Fava connection error: {e}")
raise
async def add_entry_idempotent(
self,
entry: Dict[str, Any],
idempotency_key: str
) -> Dict[str, Any]:
"""
Submit a journal entry with idempotency protection.
This method checks if an entry with the given idempotency key (as a Beancount link)
already exists before inserting. This prevents duplicate entries when the same
operation is retried (e.g., due to network issues or concurrent requests).
The idempotency key is stored as a Beancount link on the entry. Links are part
of the entry's identity and are indexed by Beancount, making lookup efficient.
Args:
entry: Beancount entry dict (same format as add_entry)
idempotency_key: Unique key for this operation (e.g., "castle-{uuid}" or "ln-{payment_hash}")
Returns:
Response from Fava if entry was created, or existing entry data if already exists
Example:
# Use payment hash as idempotency key for Lightning payments
result = await fava.add_entry_idempotent(
entry=settlement_entry,
idempotency_key=f"ln-{payment_hash[:16]}"
)
# Use expense ID for expense entries
result = await fava.add_entry_idempotent(
entry=expense_entry,
idempotency_key=f"exp-{expense_id}"
)
"""
from .beancount_format import sanitize_link
# Sanitize the idempotency key to ensure it's a valid Beancount link
safe_key = sanitize_link(idempotency_key)
# Check if entry with this link already exists
try:
entries = await self.get_journal_entries(days=30) # Check recent entries
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.put(
f"{self.base_url}/add_entries",
json={"entries": [entry]},
headers={"Content-Type": "application/json"}
)
response.raise_for_status()
result = response.json()
for existing_entry in entries:
existing_links = existing_entry.get("links", [])
if safe_key in existing_links:
logger.info(f"Entry with idempotency key '{safe_key}' already exists, skipping insert")
return {
"data": "Entry already exists (idempotent)",
"existing": True,
"entry": existing_entry
}
except Exception as e:
logger.warning(f"Could not check for existing entry with key '{safe_key}': {e}")
# Continue anyway - Beancount will error if there's a true duplicate
logger.info(f"Added entry to Fava: {result.get('data', 'Unknown')}")
return result
# Add the idempotency key as a link if not already present
if "links" not in entry:
entry["links"] = []
if safe_key not in entry["links"]:
entry["links"].append(safe_key)
# Now add the entry (this will acquire the write lock)
result = await self.add_entry(entry)
result["existing"] = False
return result
except httpx.HTTPStatusError as e:
logger.error(f"Fava HTTP error: {e.response.status_code} - {e.response.text}")
raise
except httpx.RequestError as e:
logger.error(f"Fava connection error: {e}")
raise
async def get_account_balance(self, account_name: str) -> Dict[str, Any]:
"""
@ -734,23 +623,17 @@ class FavaClient:
async def get_user_balance_bql(self, user_id: str) -> Dict[str, Any]:
"""
Get user balance using BQL with currency-grouped aggregation.
Get user balance using BQL with price notation (efficient server-side aggregation).
Groups by account AND currency to correctly handle mixed entry formats:
- Expense entries use EUR @@ SATS (position=EUR, weight=SATS)
- Payment entries use plain SATS (position=SATS, weight=SATS)
Without currency grouping, sum(number) would mix EUR and SATS face values.
The net SATS balance is computed from sum(weight) which normalizes to SATS
across both formats. Fiat is taken only from EUR rows and scaled by the
fraction of SATS debt still outstanding.
Uses sum(weight) to aggregate SATS from @@ price notation.
This provides 5-10x performance improvement over manual aggregation.
Args:
user_id: User ID
Returns:
{
"balance": int (net sats owed),
"balance": int (sats from weight column),
"fiat_balances": {"EUR": Decimal("100.50"), ...},
"accounts": [{"account": "...", "sats": 150000, "eur": Decimal("100.50")}, ...]
}
@ -763,61 +646,48 @@ class FavaClient:
user_id_prefix = user_id[:8]
# GROUP BY currency prevents mixing EUR and SATS face values in sum(number).
# sum(weight) gives SATS for both EUR @@ SATS entries and plain SATS entries.
# sum(number) on EUR rows gives the fiat amount; on SATS rows gives sats paid.
# BQL query using sum(weight) for SATS aggregation
# weight column returns the @@ price value (SATS) from price notation
query = f"""
SELECT account, currency, sum(number), sum(weight)
SELECT account, sum(number), sum(weight)
WHERE account ~ ':User-{user_id_prefix}'
AND (account ~ 'Payable' OR account ~ 'Receivable')
AND flag = '*'
GROUP BY account, currency
GROUP BY account
"""
result = await self.query_bql(query)
# First pass: collect EUR fiat totals and SATS weights per account
total_eur_sats = 0 # SATS equivalent of EUR entries (from weight)
total_sats_paid = 0 # SATS from payment entries
total_sats = 0
fiat_balances = {}
accounts = []
for row in result["rows"]:
account_name, currency, number_sum, weight_sum = row
account_name, fiat_sum, weight_sum = row
# Parse SATS from weight column (always SATS for both entry formats)
sats_weight = 0
# Parse fiat amount (sum of EUR/USD amounts)
fiat_amount = Decimal(str(fiat_sum)) if fiat_sum else Decimal(0)
# Parse SATS from weight column
# weight_sum is an Inventory dict like {"SATS": -10442635.00}
sats_amount = 0
if isinstance(weight_sum, dict) and "SATS" in weight_sum:
sats_weight = int(Decimal(str(weight_sum["SATS"])))
sats_value = weight_sum["SATS"]
sats_amount = int(Decimal(str(sats_value)))
if currency == "SATS":
# Payment entry: SATS position, track separately
total_sats_paid += int(Decimal(str(number_sum))) if number_sum else 0
else:
# EUR (fiat) entry: number is the fiat amount, weight is SATS equivalent
fiat_amount = Decimal(str(number_sum)) if number_sum else Decimal(0)
total_eur_sats += sats_weight
total_sats += sats_amount
if fiat_amount != 0:
if currency not in fiat_balances:
fiat_balances[currency] = Decimal(0)
fiat_balances[currency] += fiat_amount
# Aggregate fiat (assume EUR for now, could be extended)
if fiat_amount != 0:
if "EUR" not in fiat_balances:
fiat_balances["EUR"] = Decimal(0)
fiat_balances["EUR"] += fiat_amount
accounts.append({
"account": account_name,
"sats": sats_weight,
"eur": fiat_amount
})
# Net SATS balance: EUR-entry SATS (negative=owed) + payment SATS (positive=paid)
total_sats = total_eur_sats + total_sats_paid
# Scale fiat proportionally if partially settled
# e.g., if 80% of SATS debt paid, reduce fiat owed by 80%
if total_eur_sats != 0 and total_sats_paid != 0:
remaining_fraction = Decimal(str(total_sats)) / Decimal(str(total_eur_sats))
for currency in fiat_balances:
fiat_balances[currency] = (fiat_balances[currency] * remaining_fraction).quantize(Decimal("0.01"))
accounts.append({
"account": account_name,
"sats": sats_amount,
"eur": fiat_amount
})
logger.info(f"User {user_id[:8]} balance (BQL): {total_sats} sats, fiat: {dict(fiat_balances)}")
@ -829,14 +699,10 @@ class FavaClient:
async def get_all_user_balances_bql(self) -> List[Dict[str, Any]]:
"""
Get balances for all users using BQL with currency-grouped aggregation.
Get balances for all users using BQL with price notation (efficient admin view).
Groups by account AND currency to correctly handle mixed entry formats:
- Expense entries use EUR @@ SATS (position=EUR, weight=SATS)
- Payment entries use plain SATS (position=SATS, weight=SATS)
Without currency grouping, sum(number) would mix EUR and SATS face values,
causing wildly inflated fiat amounts for users with payment entries.
Uses sum(weight) to aggregate SATS from @@ price notation in a single query.
This provides significant performance benefits for admin views.
Returns:
[
@ -856,22 +722,23 @@ class FavaClient:
"""
from decimal import Decimal
# GROUP BY currency prevents mixing EUR and SATS face values in sum(number)
# BQL query using sum(weight) for SATS aggregation
query = """
SELECT account, currency, sum(number), sum(weight)
SELECT account, sum(number), sum(weight)
WHERE (account ~ 'Payable:User-' OR account ~ 'Receivable:User-')
AND flag = '*'
GROUP BY account, currency
GROUP BY account
"""
result = await self.query_bql(query)
# First pass: collect per-user EUR fiat totals and SATS amounts separately
# Group by user_id
user_data = {}
for row in result["rows"]:
account_name, currency, number_sum, weight_sum = row
account_name, fiat_sum, weight_sum = row
# Extract user_id from account name
if ":User-" not in account_name:
continue
@ -883,48 +750,31 @@ class FavaClient:
"user_id": user_id,
"balance": 0,
"fiat_balances": {},
"accounts": [],
"_eur_sats": 0, # SATS equivalent of EUR entries (from weight)
"_sats_paid": 0, # SATS from payment entries
"accounts": []
}
# Parse SATS from weight column (always SATS for both entry formats)
sats_weight = 0
# Parse fiat amount
fiat_amount = Decimal(str(fiat_sum)) if fiat_sum else Decimal(0)
# Parse SATS from weight column
sats_amount = 0
if isinstance(weight_sum, dict) and "SATS" in weight_sum:
sats_weight = int(Decimal(str(weight_sum["SATS"])))
sats_value = weight_sum["SATS"]
sats_amount = int(Decimal(str(sats_value)))
if currency == "SATS":
# Payment entry: SATS position, track separately
user_data[user_id]["_sats_paid"] += int(Decimal(str(number_sum))) if number_sum else 0
else:
# EUR (fiat) entry: number is the fiat amount, weight is SATS equivalent
fiat_amount = Decimal(str(number_sum)) if number_sum else Decimal(0)
user_data[user_id]["_eur_sats"] += sats_weight
user_data[user_id]["balance"] += sats_amount
if fiat_amount != 0:
if currency not in user_data[user_id]["fiat_balances"]:
user_data[user_id]["fiat_balances"][currency] = Decimal(0)
user_data[user_id]["fiat_balances"][currency] += fiat_amount
# Aggregate fiat
if fiat_amount != 0:
if "EUR" not in user_data[user_id]["fiat_balances"]:
user_data[user_id]["fiat_balances"]["EUR"] = Decimal(0)
user_data[user_id]["fiat_balances"]["EUR"] += fiat_amount
user_data[user_id]["accounts"].append({
"account": account_name,
"sats": sats_weight,
"eur": fiat_amount
})
# Second pass: compute net balances and scale fiat for partial settlements
for user_id, data in user_data.items():
eur_sats = data.pop("_eur_sats")
sats_paid = data.pop("_sats_paid")
# Net SATS balance: EUR-entry SATS (negative=owed) + payment SATS (positive=paid)
data["balance"] = eur_sats + sats_paid
# Scale fiat proportionally if partially settled
if eur_sats != 0 and sats_paid != 0:
remaining_fraction = Decimal(str(data["balance"])) / Decimal(str(eur_sats))
for currency in data["fiat_balances"]:
data["fiat_balances"][currency] = (data["fiat_balances"][currency] * remaining_fraction).quantize(Decimal("0.01"))
user_data[user_id]["accounts"].append({
"account": account_name,
"sats": sats_amount,
"eur": fiat_amount
})
logger.info(f"Fetched balances for {len(user_data)} users (BQL)")
@ -1296,10 +1146,6 @@ class FavaClient:
Returns:
New sha256sum after update
Note:
This method acquires a global write lock to prevent concurrent
modifications to the ledger file. All writes are serialized.
Example:
# Get context
context = await fava.get_entry_context("abc123")
@ -1312,28 +1158,26 @@ class FavaClient:
# Update
new_sha256 = await fava.update_entry_source("abc123", new_source, sha256)
"""
# Acquire global write lock to serialize ledger modifications
async with self._write_lock:
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.put(
f"{self.base_url}/source_slice",
json={
"entry_hash": entry_hash,
"source": new_source,
"sha256sum": sha256sum
}
)
response.raise_for_status()
result = response.json()
return result.get("data", "")
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.put(
f"{self.base_url}/source_slice",
json={
"entry_hash": entry_hash,
"source": new_source,
"sha256sum": sha256sum
}
)
response.raise_for_status()
result = response.json()
return result.get("data", "")
except httpx.HTTPStatusError as e:
logger.error(f"Fava update error: {e.response.status_code} - {e.response.text}")
raise
except httpx.RequestError as e:
logger.error(f"Fava connection error: {e}")
raise
except httpx.HTTPStatusError as e:
logger.error(f"Fava update error: {e.response.status_code} - {e.response.text}")
raise
except httpx.RequestError as e:
logger.error(f"Fava connection error: {e}")
raise
async def delete_entry(self, entry_hash: str, sha256sum: str) -> str:
"""
@ -1346,43 +1190,36 @@ class FavaClient:
Returns:
Success message
Note:
This method acquires a global write lock to prevent concurrent
modifications to the ledger file. All writes are serialized.
Example:
context = await fava.get_entry_context("abc123")
await fava.delete_entry("abc123", context["sha256sum"])
"""
# Acquire global write lock to serialize ledger modifications
async with self._write_lock:
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.delete(
f"{self.base_url}/source_slice",
params={
"entry_hash": entry_hash,
"sha256sum": sha256sum
}
)
response.raise_for_status()
result = response.json()
return result.get("data", "")
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.delete(
f"{self.base_url}/source_slice",
params={
"entry_hash": entry_hash,
"sha256sum": sha256sum
}
)
response.raise_for_status()
result = response.json()
return result.get("data", "")
except httpx.HTTPStatusError as e:
logger.error(f"Fava delete error: {e.response.status_code} - {e.response.text}")
raise
except httpx.RequestError as e:
logger.error(f"Fava connection error: {e}")
raise
except httpx.HTTPStatusError as e:
logger.error(f"Fava delete error: {e.response.status_code} - {e.response.text}")
raise
except httpx.RequestError as e:
logger.error(f"Fava connection error: {e}")
raise
async def add_account(
self,
account_name: str,
currencies: list[str],
opening_date: Optional[date] = None,
metadata: Optional[Dict[str, Any]] = None,
max_retries: int = 3
metadata: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""
Add an account to the Beancount ledger via an Open directive.
@ -1390,25 +1227,15 @@ class FavaClient:
NOTE: Fava's /api/add_entries endpoint does NOT support Open directives.
This method uses /api/source to directly edit the Beancount file.
This method implements optimistic concurrency control with retry logic:
- Acquires a global write lock before modifying the ledger
- Uses SHA256 checksum to detect concurrent modifications
- Retries with exponential backoff on checksum conflicts
- Re-checks if account was created by concurrent request before retrying
Args:
account_name: Full account name (e.g., "Assets:Receivable:User-abc123")
currencies: List of currencies for this account (e.g., ["EUR", "SATS"])
opening_date: Date to open the account (defaults to today)
metadata: Optional metadata for the account
max_retries: Maximum number of retry attempts on checksum conflict (default: 3)
Returns:
Response from Fava ({"data": "new_sha256sum", "mtime": "..."})
Raises:
ChecksumConflictError: If all retry attempts fail due to concurrent modifications
Example:
# Add a user's receivable account
result = await fava.add_account(
@ -1428,115 +1255,89 @@ class FavaClient:
if opening_date is None:
opening_date = date_type.today()
last_error = None
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
# Step 1: Get the main Beancount file path from Fava
options_response = await client.get(f"{self.base_url}/options")
options_response.raise_for_status()
options_data = options_response.json()["data"]
file_path = options_data["beancount_options"]["filename"]
for attempt in range(max_retries):
# Acquire global write lock to serialize ledger modifications
async with self._write_lock:
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
# Step 1: Get the main Beancount file path from Fava
options_response = await client.get(f"{self.base_url}/options")
options_response.raise_for_status()
options_data = options_response.json()["data"]
file_path = options_data["beancount_options"]["filename"]
logger.debug(f"Fava main file: {file_path}")
logger.debug(f"Fava main file: {file_path}")
# Step 2: Get current source file
response = await client.get(
f"{self.base_url}/source",
params={"filename": file_path}
)
response.raise_for_status()
source_data = response.json()["data"]
# Step 2: Get current source file (fresh read on each attempt)
response = await client.get(
f"{self.base_url}/source",
params={"filename": file_path}
)
response.raise_for_status()
source_data = response.json()["data"]
sha256sum = source_data["sha256sum"]
source = source_data["source"]
sha256sum = source_data["sha256sum"]
source = source_data["source"]
# Step 2: Check if account already exists
if f"open {account_name}" in source:
logger.info(f"Account {account_name} already exists in Beancount file")
return {"data": sha256sum, "mtime": source_data.get("mtime", "")}
# Step 3: Check if account already exists (may have been created by concurrent request)
if f"open {account_name}" in source:
logger.info(f"Account {account_name} already exists in Beancount file")
return {"data": sha256sum, "mtime": source_data.get("mtime", "")}
# Step 3: Find insertion point (after last Open directive AND its metadata)
lines = source.split('\n')
insert_index = 0
for i, line in enumerate(lines):
if line.strip().startswith(('open ', f'{opening_date.year}-')) and 'open' in line:
# Found an Open directive, now skip over any metadata lines
insert_index = i + 1
# Skip metadata lines (lines starting with whitespace)
while insert_index < len(lines) and lines[insert_index].startswith((' ', '\t')) and lines[insert_index].strip():
insert_index += 1
# Step 4: Find insertion point (after last Open directive AND its metadata)
lines = source.split('\n')
insert_index = 0
for i, line in enumerate(lines):
if line.strip().startswith(('open ', f'{opening_date.year}-')) and 'open' in line:
# Found an Open directive, now skip over any metadata lines
insert_index = i + 1
# Skip metadata lines (lines starting with whitespace)
while insert_index < len(lines) and lines[insert_index].startswith((' ', '\t')) and lines[insert_index].strip():
insert_index += 1
# Step 4: Format Open directive as Beancount text
currencies_str = ", ".join(currencies)
open_lines = [
"",
f"{opening_date.isoformat()} open {account_name} {currencies_str}"
]
# Step 5: Format Open directive as Beancount text
currencies_str = ", ".join(currencies)
open_lines = [
"",
f"{opening_date.isoformat()} open {account_name} {currencies_str}"
]
# Add metadata if provided
if metadata:
for key, value in metadata.items():
# Format metadata with proper indentation
if isinstance(value, str):
open_lines.append(f' {key}: "{value}"')
else:
open_lines.append(f' {key}: {value}')
# Add metadata if provided
if metadata:
for key, value in metadata.items():
# Format metadata with proper indentation
if isinstance(value, str):
open_lines.append(f' {key}: "{value}"')
else:
open_lines.append(f' {key}: {value}')
# Step 5: Insert into source
for i, line in enumerate(open_lines):
lines.insert(insert_index + i, line)
# Step 6: Insert into source
for i, line in enumerate(open_lines):
lines.insert(insert_index + i, line)
new_source = '\n'.join(lines)
new_source = '\n'.join(lines)
# Step 6: Update source file via PUT /api/source
update_payload = {
"file_path": file_path,
"source": new_source,
"sha256sum": sha256sum
}
# Step 7: Update source file via PUT /api/source
update_payload = {
"file_path": file_path,
"source": new_source,
"sha256sum": sha256sum
}
response = await client.put(
f"{self.base_url}/source",
json=update_payload,
headers={"Content-Type": "application/json"}
)
response.raise_for_status()
result = response.json()
response = await client.put(
f"{self.base_url}/source",
json=update_payload,
headers={"Content-Type": "application/json"}
)
response.raise_for_status()
result = response.json()
logger.info(f"Added account {account_name} to Beancount file with currencies {currencies}")
return result
logger.info(f"Added account {account_name} to Beancount file with currencies {currencies}")
return result
except httpx.HTTPStatusError as e:
# Check for checksum conflict (HTTP 412 Precondition Failed or similar)
if e.response.status_code in (409, 412):
last_error = ChecksumConflictError(
f"Checksum conflict on attempt {attempt + 1}/{max_retries}: {e.response.text}"
)
logger.warning(
f"Checksum conflict adding account {account_name} "
f"(attempt {attempt + 1}/{max_retries}), retrying..."
)
# Continue to retry logic below
else:
logger.error(f"Fava HTTP error adding account: {e.response.status_code} - {e.response.text}")
raise
except httpx.RequestError as e:
logger.error(f"Fava connection error: {e}")
raise
# If we get here due to checksum conflict, wait with exponential backoff before retry
if attempt < max_retries - 1:
backoff_time = 0.1 * (2 ** attempt) # 0.1s, 0.2s, 0.4s
logger.info(f"Waiting {backoff_time}s before retry...")
await asyncio.sleep(backoff_time)
# All retries exhausted
logger.error(f"Failed to add account {account_name} after {max_retries} attempts due to concurrent modifications")
raise last_error or ChecksumConflictError(f"Failed to add account after {max_retries} attempts")
except httpx.HTTPStatusError as e:
logger.error(f"Fava HTTP error adding account: {e.response.status_code} - {e.response.text}")
raise
except httpx.RequestError as e:
logger.error(f"Fava connection error: {e}")
raise
async def get_unsettled_entries_bql(
self,

View file

@ -18,29 +18,12 @@ async def get_settings(user_id: str) -> CastleSettings:
async def update_settings(user_id: str, data: CastleSettings) -> CastleSettings:
from loguru import logger
from .fava_client import init_fava_client
settings = await get_castle_settings(user_id)
if not settings:
settings = await create_castle_settings(user_id, data)
else:
settings = await update_castle_settings(user_id, data)
# Reinitialize Fava client with new settings
try:
init_fava_client(
fava_url=settings.fava_url,
ledger_slug=settings.fava_ledger_slug,
timeout=settings.fava_timeout,
)
logger.info(
f"Fava client reinitialized: {settings.fava_url}/{settings.fava_ledger_slug}"
)
except Exception as e:
logger.error(f"Failed to reinitialize Fava client: {e}")
return settings

View file

@ -31,7 +31,6 @@ window.app = Vue.createApp({
userInfo: null, // User information including equity eligibility
isAdmin: false,
isSuperUser: false,
settingsLoaded: false, // Flag to prevent race conditions on toolbar buttons
castleWalletConfigured: false,
userWalletConfigured: false,
currentExchangeRate: null, // BTC/EUR rate (sats per EUR)
@ -58,9 +57,6 @@ window.app = Vue.createApp({
settingsDialog: {
show: false,
castleWalletId: '',
favaUrl: 'http://localhost:3333',
favaLedgerSlug: 'castle-ledger',
favaTimeout: 10.0,
loading: false
},
userWalletDialog: {
@ -521,9 +517,6 @@ window.app = Vue.createApp({
} catch (error) {
// Settings not available
this.castleWalletConfigured = false
} finally {
// Mark settings as loaded to enable toolbar buttons
this.settingsLoaded = true
}
},
async loadUserWallet() {
@ -541,9 +534,6 @@ window.app = Vue.createApp({
},
showSettingsDialog() {
this.settingsDialog.castleWalletId = this.settings?.castle_wallet_id || ''
this.settingsDialog.favaUrl = this.settings?.fava_url || 'http://localhost:3333'
this.settingsDialog.favaLedgerSlug = this.settings?.fava_ledger_slug || 'castle-ledger'
this.settingsDialog.favaTimeout = this.settings?.fava_timeout || 10.0
this.settingsDialog.show = true
},
showUserWalletDialog() {
@ -559,14 +549,6 @@ window.app = Vue.createApp({
return
}
if (!this.settingsDialog.favaUrl) {
this.$q.notify({
type: 'warning',
message: 'Fava URL is required'
})
return
}
this.settingsDialog.loading = true
try {
await LNbits.api.request(
@ -574,10 +556,7 @@ window.app = Vue.createApp({
'/castle/api/v1/settings',
this.g.user.wallets[0].adminkey,
{
castle_wallet_id: this.settingsDialog.castleWalletId,
fava_url: this.settingsDialog.favaUrl,
fava_ledger_slug: this.settingsDialog.favaLedgerSlug || 'castle-ledger',
fava_timeout: parseFloat(this.settingsDialog.favaTimeout) || 10.0
castle_wallet_id: this.settingsDialog.castleWalletId
}
)
this.$q.notify({
@ -1424,7 +1403,7 @@ window.app = Vue.createApp({
maxAmount: maxAmountSats, // Positive sats amount castle owes
maxAmountFiat: maxAmountFiat, // EUR or other fiat amount (positive)
fiatCurrency: fiatCurrency,
amount: maxAmountSats, // Default to sats since lightning is the default payment method
amount: fiatCurrency ? maxAmountFiat : maxAmountSats, // Default to fiat if available
payment_method: 'lightning', // Default to lightning for paying
description: '',
reference: '',
@ -1456,9 +1435,8 @@ window.app = Vue.createApp({
memo: `Payment from Castle to ${this.payUserDialog.username}`
}
)
console.log(invoiceResponse)
const paymentRequest = invoiceResponse.data.bolt11
const paymentRequest = invoiceResponse.data.payment_request
// Pay the invoice from Castle's wallet
const paymentResponse = await LNbits.api.request(

View file

@ -1118,3 +1118,5 @@ window.app = Vue.createApp({
}
}
})
window.app.mount('#vue')

236
tasks.py
View file

@ -187,12 +187,6 @@ async def on_invoice_paid(payment: Payment) -> None:
This function is called automatically when any invoice on the Castle wallet
is paid. It checks if the invoice is a Castle payment and records it in
Beancount via Fava.
Concurrency Protection:
- Uses per-user locking to prevent race conditions when multiple payments
for the same user are processed simultaneously
- Uses idempotent entry creation to prevent duplicate entries even if
the same payment is processed multiple times
"""
# Only process Castle-specific payments
if not payment.extra or payment.extra.get("tag") != "castle":
@ -203,120 +197,134 @@ async def on_invoice_paid(payment: Payment) -> None:
logger.warning(f"Castle invoice {payment.payment_hash} missing user_id in metadata")
return
# Check if payment already recorded (idempotency)
# Query Fava for existing entry with this payment hash link
from .fava_client import get_fava_client
import httpx
fava = get_fava_client()
# Use idempotency key based on payment hash - this ensures duplicate
# processing of the same payment won't create duplicate entries
idempotency_key = f"ln-{payment.payment_hash[:16]}"
try:
# Check if payment already recorded by fetching recent entries
# Note: We can't use BQL query with `links ~ 'pattern'` because links is a set type
# and BQL doesn't support regex matching on sets. Instead, fetch entries and filter in Python.
link_to_find = f"ln-{payment.payment_hash[:16]}"
# Acquire per-user lock to serialize processing for this user
# This prevents race conditions when a user has multiple payments being processed
user_lock = fava.get_user_lock(user_id)
async with httpx.AsyncClient(timeout=5.0) as client:
# Get recent entries from Fava's journal endpoint
response = await client.get(
f"{fava.base_url}/api/journal",
params={"time": ""} # Get all entries
)
async with user_lock:
logger.info(f"Recording Castle payment {payment.payment_hash} for user {user_id[:8]} to Fava")
if response.status_code == 200:
data = response.json()
entries = data.get('entries', [])
# Check if any entry has our payment link
for entry in entries:
entry_links = entry.get('links', [])
if link_to_find in entry_links:
logger.info(f"Payment {payment.payment_hash} already recorded in Fava, skipping")
return
except Exception as e:
logger.warning(f"Could not check Fava for duplicate payment: {e}")
# Continue anyway - Fava/Beancount will catch duplicate if it exists
logger.info(f"Recording Castle payment {payment.payment_hash} for user {user_id[:8]} to Fava")
try:
from decimal import Decimal
from .crud import get_account_by_name, get_or_create_user_account
from .models import AccountType
from .beancount_format import format_net_settlement_entry
# Convert amount from millisatoshis to satoshis
amount_sats = payment.amount // 1000
# Extract fiat metadata from invoice (if present)
fiat_currency = None
fiat_amount = None
if payment.extra:
fiat_currency = payment.extra.get("fiat_currency")
fiat_amount_str = payment.extra.get("fiat_amount")
if fiat_amount_str:
fiat_amount = Decimal(str(fiat_amount_str))
if not fiat_currency or not fiat_amount:
logger.error(f"Payment {payment.payment_hash} missing fiat currency/amount metadata")
return
# Get user's current balance to determine receivables and payables
balance = await fava.get_user_balance(user_id)
fiat_balances = balance.get("fiat_balances", {})
total_fiat_balance = fiat_balances.get(fiat_currency, Decimal(0))
# Determine receivables and payables based on balance
# Positive balance = user owes castle (receivable)
# Negative balance = castle owes user (payable)
if total_fiat_balance > 0:
# User owes castle
total_receivable = total_fiat_balance
total_payable = Decimal(0)
else:
# Castle owes user
total_receivable = Decimal(0)
total_payable = abs(total_fiat_balance)
logger.info(f"Settlement: {fiat_amount} {fiat_currency} (Receivable: {total_receivable}, Payable: {total_payable})")
# Get account names
user_receivable = await get_or_create_user_account(
user_id, AccountType.ASSET, "Accounts Receivable"
)
user_payable = await get_or_create_user_account(
user_id, AccountType.LIABILITY, "Accounts Payable"
)
lightning_account = await get_account_by_name("Assets:Bitcoin:Lightning")
if not lightning_account:
logger.error("Lightning account 'Assets:Bitcoin:Lightning' not found")
return
# Query for unsettled entries to link this settlement back to them
# Net settlement can settle both expenses and receivables
settled_links = []
try:
from decimal import Decimal
from .crud import get_account_by_name, get_or_create_user_account
from .models import AccountType
from .beancount_format import format_net_settlement_entry
# Convert amount from millisatoshis to satoshis
amount_sats = payment.amount // 1000
# Extract fiat metadata from invoice (if present)
fiat_currency = None
fiat_amount = None
if payment.extra:
fiat_currency = payment.extra.get("fiat_currency")
fiat_amount_str = payment.extra.get("fiat_amount")
if fiat_amount_str:
fiat_amount = Decimal(str(fiat_amount_str))
if not fiat_currency or not fiat_amount:
logger.error(f"Payment {payment.payment_hash} missing fiat currency/amount metadata")
return
# Get user's current balance to determine receivables and payables
balance = await fava.get_user_balance(user_id)
fiat_balances = balance.get("fiat_balances", {})
total_fiat_balance = fiat_balances.get(fiat_currency, Decimal(0))
# Determine receivables and payables based on balance
# Positive balance = user owes castle (receivable)
# Negative balance = castle owes user (payable)
if total_fiat_balance > 0:
# User owes castle
total_receivable = total_fiat_balance
total_payable = Decimal(0)
else:
# Castle owes user
total_receivable = Decimal(0)
total_payable = abs(total_fiat_balance)
logger.info(f"Settlement: {fiat_amount} {fiat_currency} (Receivable: {total_receivable}, Payable: {total_payable})")
# Get account names
user_receivable = await get_or_create_user_account(
user_id, AccountType.ASSET, "Accounts Receivable"
)
user_payable = await get_or_create_user_account(
user_id, AccountType.LIABILITY, "Accounts Payable"
)
lightning_account = await get_account_by_name("Assets:Bitcoin:Lightning")
if not lightning_account:
logger.error("Lightning account 'Assets:Bitcoin:Lightning' not found")
return
# Query for unsettled entries to link this settlement back to them
# Net settlement can settle both expenses and receivables
settled_links = []
try:
unsettled_expenses = await fava.get_unsettled_entries_bql(user_id, "expense")
settled_links.extend([e["link"] for e in unsettled_expenses if e.get("link")])
unsettled_receivables = await fava.get_unsettled_entries_bql(user_id, "receivable")
settled_links.extend([e["link"] for e in unsettled_receivables if e.get("link")])
except Exception as e:
logger.warning(f"Could not query unsettled entries for settlement links: {e}")
# Continue without links - settlement will still be recorded
# Format as net settlement transaction
entry = format_net_settlement_entry(
user_id=user_id,
payment_account=lightning_account.name,
receivable_account=user_receivable.name,
payable_account=user_payable.name,
amount_sats=amount_sats,
net_fiat_amount=fiat_amount,
total_receivable_fiat=total_receivable,
total_payable_fiat=total_payable,
fiat_currency=fiat_currency,
description=f"Lightning payment settlement from user {user_id[:8]}",
entry_date=datetime.now().date(),
payment_hash=payment.payment_hash,
reference=payment.payment_hash,
settled_entry_links=settled_links if settled_links else None
)
# Submit to Fava using idempotent method to prevent duplicates
# The idempotency key is based on the payment hash, so even if this
# function is called multiple times for the same payment, only one
# entry will be created
result = await fava.add_entry_idempotent(entry, idempotency_key)
if result.get("existing"):
logger.info(
f"Payment {payment.payment_hash} was already recorded in Fava (idempotent)"
)
else:
logger.info(
f"Successfully recorded payment {payment.payment_hash} to Fava: "
f"{result.get('data', 'Unknown')}"
)
unsettled_expenses = await fava.get_unsettled_entries_bql(user_id, "expense")
settled_links.extend([e["link"] for e in unsettled_expenses if e.get("link")])
unsettled_receivables = await fava.get_unsettled_entries_bql(user_id, "receivable")
settled_links.extend([e["link"] for e in unsettled_receivables if e.get("link")])
except Exception as e:
logger.error(f"Error recording Castle payment {payment.payment_hash}: {e}")
raise
logger.warning(f"Could not query unsettled entries for settlement links: {e}")
# Continue without links - settlement will still be recorded
# Format as net settlement transaction
entry = format_net_settlement_entry(
user_id=user_id,
payment_account=lightning_account.name,
receivable_account=user_receivable.name,
payable_account=user_payable.name,
amount_sats=amount_sats,
net_fiat_amount=fiat_amount,
total_receivable_fiat=total_receivable,
total_payable_fiat=total_payable,
fiat_currency=fiat_currency,
description=f"Lightning payment settlement from user {user_id[:8]}",
entry_date=datetime.now().date(),
payment_hash=payment.payment_hash,
reference=payment.payment_hash,
settled_entry_links=settled_links if settled_links else None
)
# Submit to Fava
result = await fava.add_entry(entry)
logger.info(
f"Successfully recorded payment {payment.payment_hash} to Fava: "
f"{result.get('data', 'Unknown')}"
)
except Exception as e:
logger.error(f"Error recording Castle payment {payment.payment_hash}: {e}")
raise

View file

@ -17,14 +17,13 @@
<p class="q-mb-none">Track expenses, receivables, and balances for the collective</p>
</div>
<div class="col-auto q-gutter-xs">
<!-- Wait for settings to load before showing role-specific buttons to prevent race conditions -->
<q-btn v-if="settingsLoaded && !isSuperUser" flat round icon="account_balance_wallet" @click="showUserWalletDialog">
<q-btn v-if="!isSuperUser" flat round icon="account_balance_wallet" @click="showUserWalletDialog">
<q-tooltip>Configure Your Wallet</q-tooltip>
</q-btn>
<q-btn v-if="settingsLoaded && isSuperUser" flat round icon="admin_panel_settings" :href="'/castle/permissions'">
<q-btn v-if="isSuperUser" flat round icon="admin_panel_settings" :href="'/castle/permissions'">
<q-tooltip>Manage Permissions (Admin)</q-tooltip>
</q-btn>
<q-btn v-if="settingsLoaded && isSuperUser" flat round icon="settings" @click="showSettingsDialog">
<q-btn v-if="isSuperUser" flat round icon="settings" @click="showSettingsDialog">
<q-tooltip>Castle Settings (Super User Only)</q-tooltip>
</q-btn>
</div>
@ -33,7 +32,7 @@
</q-card>
<!-- Setup Warning -->
<q-banner v-if="settingsLoaded && !castleWalletConfigured && isSuperUser" class="bg-warning text-white" rounded>
<q-banner v-if="!castleWalletConfigured && isSuperUser" class="bg-warning text-white" rounded>
<template v-slot:avatar>
<q-icon name="warning" color="white"></q-icon>
</template>
@ -45,7 +44,7 @@
</template>
</q-banner>
<q-banner v-if="settingsLoaded && !castleWalletConfigured && !isSuperUser" class="bg-info text-white" rounded>
<q-banner v-if="!castleWalletConfigured && !isSuperUser" class="bg-info text-white" rounded>
<template v-slot:avatar>
<q-icon name="info" color="white"></q-icon>
</template>
@ -54,7 +53,7 @@
</div>
</q-banner>
<q-banner v-if="settingsLoaded && castleWalletConfigured && !userWalletConfigured && !isSuperUser" class="bg-orange text-white" rounded>
<q-banner v-if="castleWalletConfigured && !userWalletConfigured && !isSuperUser" class="bg-orange text-white" rounded>
<template v-slot:avatar>
<q-icon name="account_balance_wallet" color="white"></q-icon>
</template>
@ -1123,46 +1122,10 @@
:disable="!isSuperUser"
></q-select>
<div class="text-caption text-grey q-mb-md">
<div class="text-caption text-grey">
Select the wallet that will be used for Castle operations and transactions.
</div>
<q-separator class="q-my-md"></q-separator>
<div class="text-subtitle2 q-mb-sm">Fava/Beancount Integration</div>
<q-input
filled
dense
v-model="settingsDialog.favaUrl"
label="Fava URL *"
hint="Base URL of the Fava server (e.g., http://localhost:3333)"
:readonly="!isSuperUser"
:disable="!isSuperUser"
></q-input>
<q-input
filled
dense
v-model="settingsDialog.favaLedgerSlug"
label="Ledger Slug"
hint="Ledger identifier in Fava URL (e.g., castle-ledger)"
:readonly="!isSuperUser"
:disable="!isSuperUser"
></q-input>
<q-input
filled
dense
type="number"
step="0.5"
v-model.number="settingsDialog.favaTimeout"
label="Timeout (seconds)"
hint="Request timeout for Fava API calls"
:readonly="!isSuperUser"
:disable="!isSuperUser"
></q-input>
<div class="row q-mt-lg">
<q-btn
v-if="isSuperUser"

View file

@ -2113,7 +2113,7 @@ async def api_pay_user(
if "meta" not in entry:
entry["meta"] = {}
entry["meta"]["payment-method"] = data.payment_method
entry["meta"]["paid-by"] = auth.user_id
entry["meta"]["paid-by"] = wallet.wallet.user
if data.txid:
entry["meta"]["txid"] = data.txid
@ -2174,24 +2174,10 @@ async def api_get_user_wallet(
user_id: str,
auth: AuthContext = Depends(require_super_user),
) -> dict:
"""Get user's wallet settings (super user only)
"""Get user's wallet settings (super user only)"""
Supports both full UUIDs and truncated 8-char IDs (from Beancount accounts).
"""
from .crud import get_user_wallet_settings_by_prefix
# First try exact match
user_wallet = await get_user_wallet(user_id)
# If not found and user_id looks like a truncated ID (8 chars), try prefix match
if not user_wallet or not user_wallet.user_wallet_id:
if len(user_id) <= 8:
stored_wallet = await get_user_wallet_settings_by_prefix(user_id)
if stored_wallet and stored_wallet.user_wallet_id:
user_wallet = stored_wallet
user_id = stored_wallet.id # Use the full ID
if not user_wallet or not user_wallet.user_wallet_id:
if not user_wallet:
return {"user_id": user_id, "user_wallet_id": None}
# Get invoice key for the user's wallet (needed to generate invoices)
@ -2650,7 +2636,6 @@ async def api_approve_expense_entry(
This updates the transaction in the Beancount file via Fava API.
"""
import httpx
from .fava_client import get_fava_client
fava = get_fava_client()
@ -2659,6 +2644,7 @@ async def api_approve_expense_entry(
all_entries = await fava.get_journal_entries()
# 2. Find the entry with matching castle ID in links
target_entry_hash = None
target_entry = None
for entry in all_entries:
@ -2670,86 +2656,51 @@ async def api_approve_expense_entry(
link_clean = link.lstrip('^')
# Check if this entry has our castle ID
if link_clean == f"castle-{entry_id}" or link_clean.endswith(f"-{entry_id}"):
target_entry_hash = entry.get("entry_hash")
target_entry = entry
break
if target_entry:
if target_entry_hash:
break
if not target_entry:
if not target_entry_hash:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail=f"Pending entry {entry_id} not found in Beancount ledger"
)
# Get entry metadata for file location
meta = target_entry.get("meta", {})
filename = meta.get("filename")
lineno = meta.get("lineno")
date_str = target_entry.get("date", "")
# 3. Get the entry context (source text + sha256sum)
context = await fava.get_entry_context(target_entry_hash)
source = context.get("slice", "")
sha256sum = context.get("sha256sum", "")
if not filename or not lineno:
if not source:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Entry metadata missing filename or lineno"
detail="Could not retrieve entry source from Fava"
)
# 3. Get the source file from Fava
async with httpx.AsyncClient(timeout=fava.timeout) as client:
response = await client.get(
f"{fava.base_url}/source",
params={"filename": filename}
# 4. Change flag from ! to *
# Replace the first occurrence of the date + ! pattern
import re
date_str = target_entry.get("date", "")
old_pattern = f"{date_str} !"
new_pattern = f"{date_str} *"
if old_pattern not in source:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=f"Could not find pending flag pattern '{old_pattern}' in entry source"
)
response.raise_for_status()
source_data = response.json()["data"]
sha256sum = source_data["sha256sum"]
source = source_data["source"]
lines = source.split('\n')
new_source = source.replace(old_pattern, new_pattern, 1)
# 4. Find and modify the entry at the specified line
# Line numbers are 1-indexed, list is 0-indexed
entry_line_idx = lineno - 1
if entry_line_idx >= len(lines):
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=f"Line {lineno} not found in source file"
)
entry_line = lines[entry_line_idx]
# Check if the line contains the pending flag pattern
old_pattern = f"{date_str} !"
if old_pattern not in entry_line:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=f"Line {lineno} does not contain expected pattern '{old_pattern}'. Found: {entry_line}"
)
# Replace the flag
new_pattern = f"{date_str} *"
new_line = entry_line.replace(old_pattern, new_pattern, 1)
lines[entry_line_idx] = new_line
# 5. Write back the modified source
new_source = '\n'.join(lines)
update_response = await client.put(
f"{fava.base_url}/source",
json={
"file_path": filename,
"source": new_source,
"sha256sum": sha256sum
},
headers={"Content-Type": "application/json"}
)
update_response.raise_for_status()
logger.info(f"Entry {entry_id} approved (flag changed to *)")
# 5. Update the entry via Fava API
await fava.update_entry_source(target_entry_hash, new_source, sha256sum)
return {
"message": f"Entry {entry_id} approved successfully",
"entry_id": entry_id,
"entry_hash": target_entry_hash,
"date": date_str,
"description": target_entry.get("narration", "")
}
@ -2766,7 +2717,6 @@ async def api_reject_expense_entry(
Adds #voided tag for audit trail while keeping the '!' flag.
Voided transactions are excluded from balances but preserved in the ledger.
"""
import httpx
from .fava_client import get_fava_client
fava = get_fava_client()
@ -2775,6 +2725,7 @@ async def api_reject_expense_entry(
all_entries = await fava.get_journal_entries()
# 2. Find the entry with matching castle ID in links
target_entry_hash = None
target_entry = None
for entry in all_entries:
@ -2786,77 +2737,58 @@ async def api_reject_expense_entry(
link_clean = link.lstrip('^')
# Check if this entry has our castle ID
if link_clean == f"castle-{entry_id}" or link_clean.endswith(f"-{entry_id}"):
target_entry_hash = entry.get("entry_hash")
target_entry = entry
break
if target_entry:
if target_entry_hash:
break
if not target_entry:
if not target_entry_hash:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail=f"Pending entry {entry_id} not found in Beancount ledger"
)
# Get entry metadata for file location
meta = target_entry.get("meta", {})
filename = meta.get("filename")
lineno = meta.get("lineno")
date_str = target_entry.get("date", "")
# 3. Get the entry context (source text + sha256sum)
context = await fava.get_entry_context(target_entry_hash)
source = context.get("slice", "")
sha256sum = context.get("sha256sum", "")
if not filename or not lineno:
if not source:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Entry metadata missing filename or lineno"
detail="Could not retrieve entry source from Fava"
)
# 3. Get the source file from Fava
async with httpx.AsyncClient(timeout=fava.timeout) as client:
response = await client.get(
f"{fava.base_url}/source",
params={"filename": filename}
)
response.raise_for_status()
source_data = response.json()["data"]
# 4. Add #voided tag (keep ! flag as per convention)
date_str = target_entry.get("date", "")
sha256sum = source_data["sha256sum"]
source = source_data["source"]
# Add #voided tag if not already present
if "#voided" not in source:
# Find the transaction line and add #voided to the tags
# Pattern: date ! "narration" #existing-tags
lines = source.split('\n')
for i, line in enumerate(lines):
if date_str in line and '"' in line and '!' in line:
# Add #voided tag to the transaction line
if '#' in line:
# Already has tags, append voided
lines[i] = line.rstrip() + ' #voided'
else:
# No tags yet, add after narration
lines[i] = line.rstrip() + ' #voided'
break
new_source = '\n'.join(lines)
else:
new_source = source
# 4. Find and modify the entry at the specified line - add #voided tag
entry_line_idx = lineno - 1
if entry_line_idx >= len(lines):
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=f"Line {lineno} not found in source file"
)
entry_line = lines[entry_line_idx]
# Add #voided tag if not already present
if "#voided" not in entry_line:
# Add #voided tag to the transaction line
new_line = entry_line.rstrip() + ' #voided'
lines[entry_line_idx] = new_line
# 5. Write back the modified source
new_source = '\n'.join(lines)
update_response = await client.put(
f"{fava.base_url}/source",
json={
"file_path": filename,
"source": new_source,
"sha256sum": sha256sum
},
headers={"Content-Type": "application/json"}
)
update_response.raise_for_status()
logger.info(f"Entry {entry_id} rejected (added #voided tag)")
# 5. Update the entry via Fava API
await fava.update_entry_source(target_entry_hash, new_source, sha256sum)
return {
"message": f"Entry {entry_id} rejected (marked as voided)",
"entry_id": entry_id,
"entry_hash": target_entry_hash,
"date": date_str,
"description": target_entry.get("narration", "")
}