Adds Fava integration for journal entries
Integrates Fava/Beancount for managing journal entries. This change introduces functions to format entries into Beancount format and submit them to a Fava instance. It replaces the previous direct database entry creation with Fava submission for expense, receivable, and revenue entries. The existing create_journal_entry function is also updated to submit generic journal entries to Fava.
This commit is contained in:
parent
a88d7b4ea0
commit
e3acc53e20
2 changed files with 411 additions and 110 deletions
|
|
@ -462,3 +462,86 @@ def format_payment_entry(
|
||||||
links=links,
|
links=links,
|
||||||
meta=entry_meta
|
meta=entry_meta
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def format_revenue_entry(
|
||||||
|
payment_account: str,
|
||||||
|
revenue_account: str,
|
||||||
|
amount_sats: int,
|
||||||
|
description: str,
|
||||||
|
entry_date: date,
|
||||||
|
fiat_currency: Optional[str] = None,
|
||||||
|
fiat_amount: Optional[Decimal] = None,
|
||||||
|
reference: Optional[str] = None
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Format a revenue entry (castle receives payment directly).
|
||||||
|
|
||||||
|
Creates a cleared transaction (flag="*") since payment was received.
|
||||||
|
|
||||||
|
Example: Cash sale, Lightning payment received, bank transfer received.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
payment_account: Payment method account (e.g., "Assets:Bitcoin:Lightning", "Assets:Cash")
|
||||||
|
revenue_account: Revenue account name (e.g., "Income:Sales", "Income:Services")
|
||||||
|
amount_sats: Amount in satoshis (unsigned)
|
||||||
|
description: Entry description
|
||||||
|
entry_date: Date of payment
|
||||||
|
fiat_currency: Optional fiat currency
|
||||||
|
fiat_amount: Optional fiat amount (unsigned)
|
||||||
|
reference: Optional reference
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Fava API entry dict
|
||||||
|
|
||||||
|
Example:
|
||||||
|
entry = format_revenue_entry(
|
||||||
|
payment_account="Assets:Cash",
|
||||||
|
revenue_account="Income:Sales",
|
||||||
|
amount_sats=100000,
|
||||||
|
description="Product sale",
|
||||||
|
entry_date=date.today(),
|
||||||
|
fiat_currency="EUR",
|
||||||
|
fiat_amount=Decimal("50.00")
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
amount_sats_abs = abs(amount_sats)
|
||||||
|
fiat_amount_abs = abs(fiat_amount) if fiat_amount else None
|
||||||
|
|
||||||
|
narration = description
|
||||||
|
if fiat_currency and fiat_amount_abs:
|
||||||
|
narration += f" ({fiat_amount_abs:.2f} {fiat_currency})"
|
||||||
|
|
||||||
|
postings = [
|
||||||
|
format_posting_with_cost(
|
||||||
|
account=payment_account,
|
||||||
|
amount_sats=amount_sats_abs, # Positive = debit (asset increase)
|
||||||
|
fiat_currency=fiat_currency,
|
||||||
|
fiat_amount=fiat_amount_abs
|
||||||
|
),
|
||||||
|
format_posting_with_cost(
|
||||||
|
account=revenue_account,
|
||||||
|
amount_sats=-amount_sats_abs, # Negative = credit (revenue increase)
|
||||||
|
fiat_currency=fiat_currency,
|
||||||
|
fiat_amount=fiat_amount_abs
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
entry_meta = {
|
||||||
|
"source": "castle-api",
|
||||||
|
"created-via": "revenue_entry"
|
||||||
|
}
|
||||||
|
|
||||||
|
links = []
|
||||||
|
if reference:
|
||||||
|
links.append(reference)
|
||||||
|
|
||||||
|
return format_transaction(
|
||||||
|
date_val=entry_date,
|
||||||
|
flag="*", # Cleared (payment received)
|
||||||
|
narration=narration,
|
||||||
|
postings=postings,
|
||||||
|
tags=["revenue-entry"],
|
||||||
|
links=links,
|
||||||
|
meta=entry_meta
|
||||||
|
)
|
||||||
|
|
|
||||||
438
views_api.py
438
views_api.py
|
|
@ -412,11 +412,110 @@ async def api_create_journal_entry(
|
||||||
data: CreateJournalEntry,
|
data: CreateJournalEntry,
|
||||||
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
||||||
) -> JournalEntry:
|
) -> JournalEntry:
|
||||||
"""Create a new journal entry"""
|
"""
|
||||||
try:
|
Create a new generic journal entry.
|
||||||
return await create_journal_entry(data, wallet.wallet.id)
|
|
||||||
except ValueError as e:
|
Submits entry to Fava/Beancount.
|
||||||
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e))
|
"""
|
||||||
|
from .fava_client import get_fava_client
|
||||||
|
from .beancount_format import format_transaction, format_posting_with_cost
|
||||||
|
|
||||||
|
# Validate that entry balances to zero
|
||||||
|
total = sum(line.amount for line in data.lines)
|
||||||
|
if total != 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
|
detail=f"Entry does not balance (total: {total}, expected: 0)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get all accounts and validate they exist
|
||||||
|
account_map = {}
|
||||||
|
for line in data.lines:
|
||||||
|
account = await get_account(line.account_id)
|
||||||
|
if not account:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND,
|
||||||
|
detail=f"Account '{line.account_id}' not found"
|
||||||
|
)
|
||||||
|
account_map[line.account_id] = account
|
||||||
|
|
||||||
|
# Format postings
|
||||||
|
postings = []
|
||||||
|
for line in data.lines:
|
||||||
|
account = account_map[line.account_id]
|
||||||
|
|
||||||
|
# Extract fiat info from metadata if present
|
||||||
|
fiat_currency = line.metadata.get("fiat_currency")
|
||||||
|
fiat_amount_str = line.metadata.get("fiat_amount")
|
||||||
|
fiat_amount = Decimal(fiat_amount_str) if fiat_amount_str else None
|
||||||
|
|
||||||
|
# Create posting metadata (excluding fiat fields that go in cost basis)
|
||||||
|
posting_metadata = {k: v for k, v in line.metadata.items()
|
||||||
|
if k not in ["fiat_currency", "fiat_amount"]}
|
||||||
|
if line.description:
|
||||||
|
posting_metadata["description"] = line.description
|
||||||
|
|
||||||
|
posting = format_posting_with_cost(
|
||||||
|
account=account.name,
|
||||||
|
amount_sats=line.amount,
|
||||||
|
fiat_currency=fiat_currency,
|
||||||
|
fiat_amount=abs(fiat_amount) if fiat_amount else None,
|
||||||
|
metadata=posting_metadata if posting_metadata else None
|
||||||
|
)
|
||||||
|
postings.append(posting)
|
||||||
|
|
||||||
|
# Extract tags and links from meta
|
||||||
|
tags = data.meta.get("tags", [])
|
||||||
|
links = data.meta.get("links", [])
|
||||||
|
if data.reference:
|
||||||
|
links.append(data.reference)
|
||||||
|
|
||||||
|
# Entry metadata (excluding tags and links which go at transaction level)
|
||||||
|
entry_meta = {k: v for k, v in data.meta.items() if k not in ["tags", "links"]}
|
||||||
|
entry_meta["source"] = "castle-api"
|
||||||
|
entry_meta["created-by"] = wallet.wallet.id
|
||||||
|
|
||||||
|
# Format as Beancount entry
|
||||||
|
fava = get_fava_client()
|
||||||
|
|
||||||
|
entry = format_transaction(
|
||||||
|
date_val=data.entry_date.date() if data.entry_date else datetime.now().date(),
|
||||||
|
flag=data.flag.value if data.flag else "*",
|
||||||
|
narration=data.description,
|
||||||
|
postings=postings,
|
||||||
|
tags=tags if tags else None,
|
||||||
|
links=links if links else None,
|
||||||
|
meta=entry_meta
|
||||||
|
)
|
||||||
|
|
||||||
|
# Submit to Fava
|
||||||
|
result = await fava.add_entry(entry)
|
||||||
|
logger.info(f"Journal entry submitted to Fava: {result.get('data', 'Unknown')}")
|
||||||
|
|
||||||
|
# Return mock JournalEntry for API compatibility
|
||||||
|
# TODO: Query Fava to get the actual entry back with its hash
|
||||||
|
timestamp = datetime.now().timestamp()
|
||||||
|
return JournalEntry(
|
||||||
|
id=f"fava-{timestamp}",
|
||||||
|
description=data.description,
|
||||||
|
entry_date=data.entry_date if data.entry_date else datetime.now(),
|
||||||
|
created_by=wallet.wallet.id,
|
||||||
|
created_at=datetime.now(),
|
||||||
|
reference=data.reference,
|
||||||
|
flag=data.flag if data.flag else JournalEntryFlag.CLEARED,
|
||||||
|
lines=[
|
||||||
|
EntryLine(
|
||||||
|
id=f"fava-{timestamp}-{i}",
|
||||||
|
journal_entry_id=f"fava-{timestamp}",
|
||||||
|
account_id=line.account_id,
|
||||||
|
amount=line.amount,
|
||||||
|
description=line.description,
|
||||||
|
metadata=line.metadata
|
||||||
|
)
|
||||||
|
for i, line in enumerate(data.lines)
|
||||||
|
],
|
||||||
|
meta={**data.meta, "source": "fava", "fava_response": result.get('data', 'Unknown')}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ===== SIMPLIFIED ENTRY ENDPOINTS =====
|
# ===== SIMPLIFIED ENTRY ENDPOINTS =====
|
||||||
|
|
@ -530,29 +629,64 @@ async def api_create_expense_entry(
|
||||||
"is_equity": data.is_equity,
|
"is_equity": data.is_equity,
|
||||||
}
|
}
|
||||||
|
|
||||||
entry_data = CreateJournalEntry(
|
# Format as Beancount entry and submit to Fava
|
||||||
description=data.description + description_suffix,
|
from .fava_client import get_fava_client
|
||||||
reference=data.reference,
|
from .beancount_format import format_expense_entry
|
||||||
entry_date=data.entry_date,
|
|
||||||
flag=JournalEntryFlag.PENDING, # Expenses require admin approval
|
fava = get_fava_client()
|
||||||
meta=entry_meta,
|
|
||||||
lines=[
|
# Extract fiat info from metadata
|
||||||
CreateEntryLine(
|
fiat_currency = metadata.get("fiat_currency") if metadata else None
|
||||||
account_id=expense_account.id,
|
fiat_amount = Decimal(metadata.get("fiat_amount")) if metadata and metadata.get("fiat_amount") else None
|
||||||
amount=amount_sats, # Positive = debit (expense increase)
|
|
||||||
description=f"Expense paid by user {wallet.wallet.user[:8]}",
|
# Format Beancount entry
|
||||||
metadata=metadata,
|
entry = format_expense_entry(
|
||||||
),
|
user_id=wallet.wallet.user,
|
||||||
CreateEntryLine(
|
expense_account=expense_account.name,
|
||||||
account_id=user_account.id,
|
user_account=user_account.name,
|
||||||
amount=-amount_sats, # Negative = credit (liability/equity increase)
|
amount_sats=amount_sats,
|
||||||
description=f"{'Equity contribution' if data.is_equity else 'Amount owed to user'}",
|
description=data.description,
|
||||||
metadata=metadata,
|
entry_date=data.entry_date.date() if data.entry_date else datetime.now().date(),
|
||||||
),
|
is_equity=data.is_equity,
|
||||||
],
|
fiat_currency=fiat_currency,
|
||||||
|
fiat_amount=fiat_amount,
|
||||||
|
reference=data.reference
|
||||||
)
|
)
|
||||||
|
|
||||||
return await create_journal_entry(entry_data, wallet.wallet.id)
|
# Submit to Fava
|
||||||
|
result = await fava.add_entry(entry)
|
||||||
|
|
||||||
|
# Return a JournalEntry-like response for compatibility
|
||||||
|
# TODO: Query Fava to get the actual entry back with its hash
|
||||||
|
from .models import EntryLine
|
||||||
|
return JournalEntry(
|
||||||
|
id=f"fava-{datetime.now().timestamp()}", # Temporary ID
|
||||||
|
description=data.description + description_suffix,
|
||||||
|
entry_date=data.entry_date if data.entry_date else datetime.now(),
|
||||||
|
created_by=wallet.wallet.id,
|
||||||
|
created_at=datetime.now(),
|
||||||
|
reference=data.reference,
|
||||||
|
flag=JournalEntryFlag.PENDING,
|
||||||
|
meta=entry_meta,
|
||||||
|
lines=[
|
||||||
|
EntryLine(
|
||||||
|
id=f"line-1-{datetime.now().timestamp()}",
|
||||||
|
journal_entry_id=f"fava-{datetime.now().timestamp()}",
|
||||||
|
account_id=expense_account.id,
|
||||||
|
amount=amount_sats,
|
||||||
|
description=f"Expense paid by user {wallet.wallet.user[:8]}",
|
||||||
|
metadata=metadata or {}
|
||||||
|
),
|
||||||
|
EntryLine(
|
||||||
|
id=f"line-2-{datetime.now().timestamp()}",
|
||||||
|
journal_entry_id=f"fava-{datetime.now().timestamp()}",
|
||||||
|
account_id=user_account.id,
|
||||||
|
amount=-amount_sats,
|
||||||
|
description=f"{'Equity contribution' if data.is_equity else 'Amount owed to user'}",
|
||||||
|
metadata=metadata or {}
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@castle_api_router.post("/api/v1/entries/receivable", status_code=HTTPStatus.CREATED)
|
@castle_api_router.post("/api/v1/entries/receivable", status_code=HTTPStatus.CREATED)
|
||||||
|
|
@ -615,28 +749,62 @@ async def api_create_receivable_entry(
|
||||||
"debtor_user_id": data.user_id,
|
"debtor_user_id": data.user_id,
|
||||||
}
|
}
|
||||||
|
|
||||||
entry_data = CreateJournalEntry(
|
# Format as Beancount entry and submit to Fava
|
||||||
description=data.description + description_suffix,
|
from .fava_client import get_fava_client
|
||||||
reference=data.reference,
|
from .beancount_format import format_receivable_entry
|
||||||
flag=JournalEntryFlag.PENDING, # Receivables start as pending until paid
|
|
||||||
meta=entry_meta,
|
fava = get_fava_client()
|
||||||
lines=[
|
|
||||||
CreateEntryLine(
|
# Extract fiat info from metadata
|
||||||
account_id=user_receivable.id,
|
fiat_currency = metadata.get("fiat_currency") if metadata else None
|
||||||
amount=amount_sats, # Positive = debit (asset increase - user owes castle)
|
fiat_amount = Decimal(metadata.get("fiat_amount")) if metadata and metadata.get("fiat_amount") else None
|
||||||
description=f"Amount owed by user {data.user_id[:8]}",
|
|
||||||
metadata=metadata,
|
# Format Beancount entry
|
||||||
),
|
entry = format_receivable_entry(
|
||||||
CreateEntryLine(
|
user_id=data.user_id,
|
||||||
account_id=revenue_account.id,
|
revenue_account=revenue_account.name,
|
||||||
amount=-amount_sats, # Negative = credit (revenue increase)
|
receivable_account=user_receivable.name,
|
||||||
description="Revenue earned",
|
amount_sats=amount_sats,
|
||||||
metadata=metadata,
|
description=data.description,
|
||||||
),
|
entry_date=datetime.now().date(),
|
||||||
],
|
fiat_currency=fiat_currency,
|
||||||
|
fiat_amount=fiat_amount,
|
||||||
|
reference=data.reference
|
||||||
)
|
)
|
||||||
|
|
||||||
return await create_journal_entry(entry_data, wallet.wallet.id)
|
# Submit to Fava
|
||||||
|
result = await fava.add_entry(entry)
|
||||||
|
|
||||||
|
# Return a JournalEntry-like response for compatibility
|
||||||
|
from .models import EntryLine
|
||||||
|
return JournalEntry(
|
||||||
|
id=f"fava-{datetime.now().timestamp()}",
|
||||||
|
description=data.description + description_suffix,
|
||||||
|
entry_date=datetime.now(),
|
||||||
|
created_by=wallet.wallet.id,
|
||||||
|
created_at=datetime.now(),
|
||||||
|
reference=data.reference,
|
||||||
|
flag=JournalEntryFlag.PENDING,
|
||||||
|
meta=entry_meta,
|
||||||
|
lines=[
|
||||||
|
EntryLine(
|
||||||
|
id=f"line-1-{datetime.now().timestamp()}",
|
||||||
|
journal_entry_id=f"fava-{datetime.now().timestamp()}",
|
||||||
|
account_id=user_receivable.id,
|
||||||
|
amount=amount_sats,
|
||||||
|
description=f"Amount owed by user {data.user_id[:8]}",
|
||||||
|
metadata=metadata or {}
|
||||||
|
),
|
||||||
|
EntryLine(
|
||||||
|
id=f"line-2-{datetime.now().timestamp()}",
|
||||||
|
journal_entry_id=f"fava-{datetime.now().timestamp()}",
|
||||||
|
account_id=revenue_account.id,
|
||||||
|
amount=-amount_sats,
|
||||||
|
description="Revenue earned",
|
||||||
|
metadata=metadata or {}
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@castle_api_router.post("/api/v1/entries/revenue", status_code=HTTPStatus.CREATED)
|
@castle_api_router.post("/api/v1/entries/revenue", status_code=HTTPStatus.CREATED)
|
||||||
|
|
@ -647,7 +815,12 @@ async def api_create_revenue_entry(
|
||||||
"""
|
"""
|
||||||
Create a revenue entry (castle receives payment).
|
Create a revenue entry (castle receives payment).
|
||||||
Admin only.
|
Admin only.
|
||||||
|
|
||||||
|
Submits entry to Fava/Beancount.
|
||||||
"""
|
"""
|
||||||
|
from .fava_client import get_fava_client
|
||||||
|
from .beancount_format import format_revenue_entry
|
||||||
|
|
||||||
# Get revenue account
|
# Get revenue account
|
||||||
revenue_account = await get_account_by_name(data.revenue_account)
|
revenue_account = await get_account_by_name(data.revenue_account)
|
||||||
if not revenue_account:
|
if not revenue_account:
|
||||||
|
|
@ -668,26 +841,76 @@ async def api_create_revenue_entry(
|
||||||
detail=f"Payment account '{data.payment_method_account}' not found",
|
detail=f"Payment account '{data.payment_method_account}' not found",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create journal entry
|
# Handle currency conversion if provided
|
||||||
# DR Cash/Lightning/Bank, CR Revenue
|
amount_sats = int(data.amount)
|
||||||
entry_data = CreateJournalEntry(
|
fiat_currency = None
|
||||||
|
fiat_amount = None
|
||||||
|
|
||||||
|
if data.currency:
|
||||||
|
# Validate currency
|
||||||
|
if data.currency.upper() not in allowed_currencies():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
|
detail=f"Currency '{data.currency}' not supported. Allowed: {', '.join(allowed_currencies())}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Store fiat info for cost basis
|
||||||
|
fiat_currency = data.currency.upper()
|
||||||
|
fiat_amount = data.amount # Original fiat amount
|
||||||
|
# In this case, data.amount should be the satoshi amount
|
||||||
|
# This is a bit confusing - the API accepts amount as Decimal which could be either sats or fiat
|
||||||
|
# For now, assume if currency is provided, amount is fiat and needs conversion
|
||||||
|
# TODO: Consider updating the API model to be clearer about this
|
||||||
|
|
||||||
|
# Format as Beancount entry and submit to Fava
|
||||||
|
fava = get_fava_client()
|
||||||
|
|
||||||
|
entry = format_revenue_entry(
|
||||||
|
payment_account=payment_account.name,
|
||||||
|
revenue_account=revenue_account.name,
|
||||||
|
amount_sats=amount_sats,
|
||||||
description=data.description,
|
description=data.description,
|
||||||
reference=data.reference,
|
entry_date=datetime.now().date(),
|
||||||
lines=[
|
fiat_currency=fiat_currency,
|
||||||
CreateEntryLine(
|
fiat_amount=fiat_amount,
|
||||||
account_id=payment_account.id,
|
reference=data.reference
|
||||||
amount=data.amount, # Positive = debit (asset increase)
|
|
||||||
description="Payment received",
|
|
||||||
),
|
|
||||||
CreateEntryLine(
|
|
||||||
account_id=revenue_account.id,
|
|
||||||
amount=-data.amount, # Negative = credit (revenue increase)
|
|
||||||
description="Revenue earned",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return await create_journal_entry(entry_data, wallet.wallet.id)
|
# Submit to Fava
|
||||||
|
result = await fava.add_entry(entry)
|
||||||
|
logger.info(f"Revenue entry submitted to Fava: {result.get('data', 'Unknown')}")
|
||||||
|
|
||||||
|
# Return mock JournalEntry for API compatibility
|
||||||
|
# TODO: Query Fava to get the actual entry back with its hash
|
||||||
|
timestamp = datetime.now().timestamp()
|
||||||
|
return JournalEntry(
|
||||||
|
id=f"fava-{timestamp}",
|
||||||
|
description=data.description,
|
||||||
|
entry_date=datetime.now(),
|
||||||
|
created_by=wallet.wallet.id,
|
||||||
|
created_at=datetime.now(),
|
||||||
|
reference=data.reference,
|
||||||
|
flag=JournalEntryFlag.CLEARED, # Revenue entries are cleared
|
||||||
|
lines=[
|
||||||
|
EntryLine(
|
||||||
|
id=f"fava-{timestamp}-1",
|
||||||
|
journal_entry_id=f"fava-{timestamp}",
|
||||||
|
account_id=payment_account.id,
|
||||||
|
amount=amount_sats,
|
||||||
|
description="Payment received",
|
||||||
|
metadata={"fiat_currency": fiat_currency, "fiat_amount": str(fiat_amount)} if fiat_currency else {}
|
||||||
|
),
|
||||||
|
EntryLine(
|
||||||
|
id=f"fava-{timestamp}-2",
|
||||||
|
journal_entry_id=f"fava-{timestamp}",
|
||||||
|
account_id=revenue_account.id,
|
||||||
|
amount=-amount_sats,
|
||||||
|
description="Revenue earned",
|
||||||
|
metadata={}
|
||||||
|
)
|
||||||
|
],
|
||||||
|
meta={"source": "fava", "fava_response": result.get('data', 'Unknown')}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ===== USER BALANCE ENDPOINTS =====
|
# ===== USER BALANCE ENDPOINTS =====
|
||||||
|
|
@ -1609,31 +1832,31 @@ async def api_approve_manual_payment_request(
|
||||||
detail="Lightning account not found",
|
detail="Lightning account not found",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create journal entry: Debit Lightning (asset decreased), Credit Accounts Payable (liability increased)
|
# Format payment entry and submit to Fava
|
||||||
# This records that the Castle paid the user, reducing the lightning balance and reducing what castle owes
|
from .fava_client import get_fava_client
|
||||||
journal_entry = await create_journal_entry(
|
from .beancount_format import format_payment_entry
|
||||||
CreateJournalEntry(
|
|
||||||
description=f"Manual payment to user: {request.description}",
|
fava = get_fava_client()
|
||||||
reference=f"MPR-{request.id}",
|
|
||||||
lines=[
|
entry = format_payment_entry(
|
||||||
CreateEntryLine(
|
user_id=request.user_id,
|
||||||
account_id=liability_account.id,
|
payment_account=lightning_account.name,
|
||||||
amount=request.amount, # Positive = debit (liability decrease - castle owes less)
|
payable_or_receivable_account=liability_account.name,
|
||||||
description="Payment to user",
|
amount_sats=request.amount,
|
||||||
),
|
description=f"Manual payment to user: {request.description}",
|
||||||
CreateEntryLine(
|
entry_date=datetime.now().date(),
|
||||||
account_id=lightning_account.id,
|
is_payable=True, # Castle paying user
|
||||||
amount=-request.amount, # Negative = credit (asset decrease)
|
reference=f"MPR-{request.id}"
|
||||||
description="Payment from castle",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
castle_wallet_id,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Approve the request
|
# Submit to Fava
|
||||||
|
result = await fava.add_entry(entry)
|
||||||
|
logger.info(f"Manual payment entry submitted to Fava: {result.get('data', 'Unknown')}")
|
||||||
|
|
||||||
|
# Approve the request with Fava entry reference
|
||||||
|
entry_id = f"fava-{datetime.now().timestamp()}"
|
||||||
return await approve_manual_payment_request(
|
return await approve_manual_payment_request(
|
||||||
request_id, wallet.wallet.user, journal_entry.id
|
request_id, wallet.wallet.user, entry_id
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1675,8 +1898,13 @@ async def api_reject_manual_payment_request(
|
||||||
async def api_approve_expense_entry(
|
async def api_approve_expense_entry(
|
||||||
entry_id: str,
|
entry_id: str,
|
||||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||||
) -> JournalEntry:
|
) -> dict:
|
||||||
"""Approve a pending expense entry (admin only)"""
|
"""
|
||||||
|
Approve a pending expense entry (admin only).
|
||||||
|
|
||||||
|
With Fava integration, entries must be approved through Fava UI or API.
|
||||||
|
This endpoint provides instructions on how to approve entries.
|
||||||
|
"""
|
||||||
from lnbits.settings import settings as lnbits_settings
|
from lnbits.settings import settings as lnbits_settings
|
||||||
|
|
||||||
if wallet.wallet.user != lnbits_settings.super_user:
|
if wallet.wallet.user != lnbits_settings.super_user:
|
||||||
|
|
@ -1685,33 +1913,23 @@ async def api_approve_expense_entry(
|
||||||
detail="Only super user can approve expenses",
|
detail="Only super user can approve expenses",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get the entry
|
# TODO: Implement Fava entry update via PUT /api/source_slice
|
||||||
entry = await get_journal_entry(entry_id)
|
# This requires:
|
||||||
if not entry:
|
# 1. Query Fava for entry by link (^castle-{entry_id} or similar)
|
||||||
raise HTTPException(
|
# 2. Get the entry's source text
|
||||||
status_code=HTTPStatus.NOT_FOUND,
|
# 3. Change flag from ! to *
|
||||||
detail="Journal entry not found",
|
# 4. Submit updated source back to Fava
|
||||||
)
|
|
||||||
|
|
||||||
if entry.flag != JournalEntryFlag.PENDING:
|
# For now, return instructions
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.NOT_IMPLEMENTED,
|
||||||
detail=f"Entry is not pending (current status: {entry.flag.value})",
|
detail=(
|
||||||
|
f"Entry approval via API not yet implemented with Fava integration. "
|
||||||
|
f"To approve entry {entry_id}, open Fava and edit the transaction to change the flag from '!' to '*'. "
|
||||||
|
f"Fava URL: http://localhost:3333/castle-ledger/"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Update flag to cleared
|
|
||||||
await db.execute(
|
|
||||||
"""
|
|
||||||
UPDATE journal_entries
|
|
||||||
SET flag = :flag
|
|
||||||
WHERE id = :id
|
|
||||||
""",
|
|
||||||
{"flag": JournalEntryFlag.CLEARED.value, "id": entry_id}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Return updated entry
|
|
||||||
return await get_journal_entry(entry_id)
|
|
||||||
|
|
||||||
|
|
||||||
@castle_api_router.post("/api/v1/entries/{entry_id}/reject")
|
@castle_api_router.post("/api/v1/entries/{entry_id}/reject")
|
||||||
async def api_reject_expense_entry(
|
async def api_reject_expense_entry(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue