diff --git a/beancount_format.py b/beancount_format.py index df25d17..b895124 100644 --- a/beancount_format.py +++ b/beancount_format.py @@ -462,3 +462,86 @@ def format_payment_entry( links=links, 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 + ) diff --git a/views_api.py b/views_api.py index cb4f4fb..8389d32 100644 --- a/views_api.py +++ b/views_api.py @@ -412,11 +412,110 @@ async def api_create_journal_entry( data: CreateJournalEntry, wallet: WalletTypeInfo = Depends(require_invoice_key), ) -> JournalEntry: - """Create a new journal entry""" - try: - return await create_journal_entry(data, wallet.wallet.id) - except ValueError as e: - raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e)) + """ + Create a new generic journal entry. + + Submits entry to Fava/Beancount. + """ + 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 ===== @@ -530,29 +629,64 @@ async def api_create_expense_entry( "is_equity": data.is_equity, } - entry_data = CreateJournalEntry( - description=data.description + description_suffix, - reference=data.reference, - entry_date=data.entry_date, - flag=JournalEntryFlag.PENDING, # Expenses require admin approval - meta=entry_meta, - lines=[ - CreateEntryLine( - account_id=expense_account.id, - amount=amount_sats, # Positive = debit (expense increase) - description=f"Expense paid by user {wallet.wallet.user[:8]}", - metadata=metadata, - ), - CreateEntryLine( - account_id=user_account.id, - amount=-amount_sats, # Negative = credit (liability/equity increase) - description=f"{'Equity contribution' if data.is_equity else 'Amount owed to user'}", - metadata=metadata, - ), - ], + # Format as Beancount entry and submit to Fava + from .fava_client import get_fava_client + from .beancount_format import format_expense_entry + + fava = get_fava_client() + + # Extract fiat info from metadata + fiat_currency = metadata.get("fiat_currency") if metadata else None + fiat_amount = Decimal(metadata.get("fiat_amount")) if metadata and metadata.get("fiat_amount") else None + + # Format Beancount entry + entry = format_expense_entry( + user_id=wallet.wallet.user, + expense_account=expense_account.name, + user_account=user_account.name, + amount_sats=amount_sats, + description=data.description, + 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) @@ -615,28 +749,62 @@ async def api_create_receivable_entry( "debtor_user_id": data.user_id, } - entry_data = CreateJournalEntry( - description=data.description + description_suffix, - reference=data.reference, - flag=JournalEntryFlag.PENDING, # Receivables start as pending until paid - meta=entry_meta, - lines=[ - CreateEntryLine( - account_id=user_receivable.id, - amount=amount_sats, # Positive = debit (asset increase - user owes castle) - description=f"Amount owed by user {data.user_id[:8]}", - metadata=metadata, - ), - CreateEntryLine( - account_id=revenue_account.id, - amount=-amount_sats, # Negative = credit (revenue increase) - description="Revenue earned", - metadata=metadata, - ), - ], + # Format as Beancount entry and submit to Fava + from .fava_client import get_fava_client + from .beancount_format import format_receivable_entry + + fava = get_fava_client() + + # Extract fiat info from metadata + fiat_currency = metadata.get("fiat_currency") if metadata else None + fiat_amount = Decimal(metadata.get("fiat_amount")) if metadata and metadata.get("fiat_amount") else None + + # Format Beancount entry + entry = format_receivable_entry( + user_id=data.user_id, + revenue_account=revenue_account.name, + receivable_account=user_receivable.name, + amount_sats=amount_sats, + 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) @@ -647,7 +815,12 @@ async def api_create_revenue_entry( """ Create a revenue entry (castle receives payment). Admin only. + + Submits entry to Fava/Beancount. """ + from .fava_client import get_fava_client + from .beancount_format import format_revenue_entry + # Get revenue account revenue_account = await get_account_by_name(data.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", ) - # Create journal entry - # DR Cash/Lightning/Bank, CR Revenue - entry_data = CreateJournalEntry( + # Handle currency conversion if provided + amount_sats = int(data.amount) + 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, - reference=data.reference, - lines=[ - CreateEntryLine( - account_id=payment_account.id, - 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", - ), - ], + 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) + 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 ===== @@ -1609,31 +1832,31 @@ async def api_approve_manual_payment_request( detail="Lightning account not found", ) - # Create journal entry: Debit Lightning (asset decreased), Credit Accounts Payable (liability increased) - # This records that the Castle paid the user, reducing the lightning balance and reducing what castle owes - journal_entry = await create_journal_entry( - CreateJournalEntry( - description=f"Manual payment to user: {request.description}", - reference=f"MPR-{request.id}", - lines=[ - CreateEntryLine( - account_id=liability_account.id, - amount=request.amount, # Positive = debit (liability decrease - castle owes less) - description="Payment to user", - ), - CreateEntryLine( - account_id=lightning_account.id, - amount=-request.amount, # Negative = credit (asset decrease) - description="Payment from castle", - ), - ], - ), - castle_wallet_id, + # Format payment entry and submit to Fava + from .fava_client import get_fava_client + from .beancount_format import format_payment_entry + + fava = get_fava_client() + + entry = format_payment_entry( + user_id=request.user_id, + payment_account=lightning_account.name, + payable_or_receivable_account=liability_account.name, + amount_sats=request.amount, + description=f"Manual payment to user: {request.description}", + entry_date=datetime.now().date(), + is_payable=True, # Castle paying user + reference=f"MPR-{request.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( - 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( entry_id: str, wallet: WalletTypeInfo = Depends(require_admin_key), -) -> JournalEntry: - """Approve a pending expense entry (admin only)""" +) -> dict: + """ + 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 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", ) - # Get the entry - entry = await get_journal_entry(entry_id) - if not entry: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, - detail="Journal entry not found", - ) + # TODO: Implement Fava entry update via PUT /api/source_slice + # This requires: + # 1. Query Fava for entry by link (^castle-{entry_id} or similar) + # 2. Get the entry's source text + # 3. Change flag from ! to * + # 4. Submit updated source back to Fava - if entry.flag != JournalEntryFlag.PENDING: - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail=f"Entry is not pending (current status: {entry.flag.value})", + # For now, return instructions + raise HTTPException( + status_code=HTTPStatus.NOT_IMPLEMENTED, + 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") async def api_reject_expense_entry(