From 93b5c2677cbed700577bcf18830db01cb91675e9 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 16 May 2026 19:01:47 +0200 Subject: [PATCH] Add user-facing income/revenue submission endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the existing expense submission flow so non-admin users can log income on behalf of the organization for super-user review. New endpoint POST /api/v1/entries/income takes invoice-key auth, creates a Beancount transaction with the pending '!' flag, and reuses the existing /entries/{id}/approve and /reject endpoints (which match by libra-{id} link regardless of entry type). Adds PermissionType.SUBMIT_INCOME granted on revenue accounts (parallel to SUBMIT_EXPENSE on expense accounts) rather than overloading SUBMIT_EXPENSE — the two operations target distinct account types and should be grantable independently. Enforces AccountType.REVENUE on the income account and AccountType.ASSET on the payment-method account; fiat currency is required (matches the expense flow's effective requirement). Income entries get a 'income-entry' tag and an ^inc-{entry_id} link for tracking, and surface in the existing /entries/pending list for super-user approval. UI work lives in the standalone webapp, out of scope here. Closes #9 Co-Authored-By: Claude Opus 4.7 (1M context) --- beancount_format.py | 64 ++++++++++++++++++++ models.py | 13 ++++ views_api.py | 142 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 219 insertions(+) diff --git a/beancount_format.py b/beancount_format.py index 956a4ee..446ce26 100644 --- a/beancount_format.py +++ b/beancount_format.py @@ -885,3 +885,67 @@ def format_revenue_entry( links=links, meta=entry_meta ) + + +def format_income_entry( + user_id: str, + payment_account: str, + revenue_account: str, + amount_sats: int, + description: str, + entry_date: date, + fiat_currency: str, + fiat_amount: Decimal, + reference: Optional[str] = None, + entry_id: Optional[str] = None, +) -> Dict[str, Any]: + """ + Format a user-submitted income/revenue entry for Fava (pending approval). + + Mirrors format_expense_entry: pending flag (!) for super-user review, + fiat-first price notation (@@ SATS) for BQL queryability, unique link + (^inc-{entry_id}) for tracking through the approve/reject flow. + + Postings: DR payment_account (asset receives funds), CR revenue_account. + """ + if not fiat_currency or not fiat_amount or fiat_amount <= 0: + raise ValueError("fiat_currency and a positive fiat_amount are required for income entries") + + if not entry_id: + entry_id = generate_entry_id() + + fiat_amount_abs = abs(fiat_amount) + sats_abs = abs(amount_sats) + + narration = f"{description} ({fiat_amount_abs:.2f} {fiat_currency})" + + postings = [ + { + "account": payment_account, + "amount": f"{fiat_amount_abs:.2f} {fiat_currency} @@ {sats_abs} SATS", + }, + { + "account": revenue_account, + "amount": f"-{fiat_amount_abs:.2f} {fiat_currency} @@ {sats_abs} SATS", + }, + ] + + entry_meta = { + "user-id": user_id, + "source": "libra-api", + "entry-id": entry_id, + } + + links = [f"inc-{entry_id}"] + if reference: + links.append(sanitize_link(reference)) + + return format_transaction( + date_val=entry_date, + flag="!", # Pending - requires admin approval + narration=narration, + postings=postings, + tags=["income-entry"], + links=links, + meta=entry_meta, + ) diff --git a/models.py b/models.py index eaf75c4..9fe3b9a 100644 --- a/models.py +++ b/models.py @@ -127,6 +127,18 @@ class RevenueEntry(BaseModel): currency: Optional[str] = None # If None, amount is in satoshis. Otherwise, fiat currency code +class IncomeEntry(BaseModel): + """Helper model for user-facing income/revenue submission (pending approval)""" + + description: str + amount: Decimal # Fiat amount in the specified currency + revenue_account: str # Income/Revenue account name or ID + payment_method_account: str # Asset account receiving the funds (Cash, Bank, Lightning) + currency: str # Required: fiat currency code (EUR, USD, etc.) + reference: Optional[str] = None + entry_date: Optional[datetime] = None + + class LibraSettings(BaseModel): """Settings for the Libra extension""" @@ -295,6 +307,7 @@ class PermissionType(str, Enum): """Types of permissions for account access""" READ = "read" # Can view account and its balance SUBMIT_EXPENSE = "submit_expense" # Can submit expenses to this account + SUBMIT_INCOME = "submit_income" # Can submit income/revenue to this account MANAGE = "manage" # Can modify account (admin level) diff --git a/views_api.py b/views_api.py index 8c22f96..f77bea7 100644 --- a/views_api.py +++ b/views_api.py @@ -62,6 +62,7 @@ from .models import ( CreateUserEquityStatus, ExpenseEntry, GeneratePaymentInvoice, + IncomeEntry, JournalEntry, JournalEntryFlag, ManualPaymentRequest, @@ -1198,6 +1199,147 @@ async def api_create_expense_entry( ) +@libra_api_router.post("/api/v1/entries/income", status_code=HTTPStatus.CREATED) +async def api_create_income_entry( + data: IncomeEntry, + wallet: WalletTypeInfo = Depends(require_invoice_key), +) -> JournalEntry: + """ + Create a user-submitted income/revenue entry (pending approval). + + Mirrors the expense submission flow: entry is created with '!' (pending) + flag and goes through the same /api/v1/entries/{id}/approve and /reject + endpoints. Requires SUBMIT_INCOME permission on the revenue account. + + Postings: DR payment_method_account (asset), CR revenue_account. + """ + # Validate currency + if data.currency.upper() not in allowed_currencies(): + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=f"Currency '{data.currency}' not allowed. Use one of: {', '.join(allowed_currencies())}", + ) + + # Resolve revenue account by name or ID + revenue_account = await get_account_by_name(data.revenue_account) + if not revenue_account: + revenue_account = await get_account(data.revenue_account) + if not revenue_account: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail=f"Revenue account '{data.revenue_account}' not found", + ) + if revenue_account.account_type != AccountType.REVENUE: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=f"Account '{revenue_account.name}' is not a revenue account (type: {revenue_account.account_type.value})", + ) + + # Resolve payment method account by name or ID + payment_account = await get_account_by_name(data.payment_method_account) + if not payment_account: + payment_account = await get_account(data.payment_method_account) + if not payment_account: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail=f"Payment account '{data.payment_method_account}' not found", + ) + if payment_account.account_type != AccountType.ASSET: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=f"Account '{payment_account.name}' is not an asset account (type: {payment_account.account_type.value})", + ) + + # Permission check on the revenue account + from .crud import get_user_permissions_with_inheritance + + submit_perms = await get_user_permissions_with_inheritance( + wallet.wallet.user, revenue_account.name, PermissionType.SUBMIT_INCOME + ) + if not submit_perms: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail=f"You do not have permission to submit income to account '{revenue_account.name}'. Please contact an administrator to request access.", + ) + + # Convert fiat to sats + fiat_currency = data.currency.upper() + amount_sats = await fiat_amount_as_satoshis(float(data.amount), data.currency) + + metadata = { + "fiat_currency": fiat_currency, + "fiat_amount": str(data.amount.quantize(Decimal("0.001"))), + "fiat_rate": float(amount_sats) / float(data.amount) if data.amount > 0 else 0, + "btc_rate": float(data.amount) / float(amount_sats) * 100_000_000 if amount_sats > 0 else 0, + } + + # Submit to Fava + from .fava_client import get_fava_client + from .beancount_format import format_income_entry, sanitize_link + + fava = get_fava_client() + + import uuid + entry_id = str(uuid.uuid4()).replace("-", "")[:16] + + libra_reference = f"libra-{entry_id}" + if data.reference: + libra_reference = f"{sanitize_link(data.reference)}-{entry_id}" + + entry = format_income_entry( + user_id=wallet.wallet.user, + payment_account=payment_account.name, + revenue_account=revenue_account.name, + amount_sats=amount_sats, + description=data.description, + entry_date=data.entry_date.date() if data.entry_date else datetime.now().date(), + fiat_currency=fiat_currency, + fiat_amount=data.amount, + reference=libra_reference, + entry_id=entry_id, + ) + + result = await fava.add_entry(entry) + logger.info(f"Income entry {entry_id} submitted to Fava (pending): {result.get('data', 'Unknown')}") + + description_suffix = f" ({metadata['fiat_amount']} {fiat_currency})" + entry_meta = { + "source": "api", + "created_via": "income_entry", + "user_id": wallet.wallet.user, + } + + from .models import EntryLine + return JournalEntry( + id=entry_id, + description=data.description + description_suffix, + entry_date=data.entry_date if data.entry_date else datetime.now(), + created_by=wallet.wallet.user, + created_at=datetime.now(), + reference=libra_reference, + flag=JournalEntryFlag.PENDING, + meta=entry_meta, + lines=[ + EntryLine( + id=f"line-1-{entry_id}", + journal_entry_id=entry_id, + account_id=payment_account.id, + amount=amount_sats, + description=f"Income received into {payment_account.name}", + metadata=metadata, + ), + EntryLine( + id=f"line-2-{entry_id}", + journal_entry_id=entry_id, + account_id=revenue_account.id, + amount=-amount_sats, + description="Revenue earned (pending approval)", + metadata=metadata, + ), + ], + ) + + @libra_api_router.post("/api/v1/entries/receivable", status_code=HTTPStatus.CREATED) async def api_create_receivable_entry( data: ReceivableEntry,