Add user-facing income/revenue submission endpoint

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) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-05-16 19:01:47 +02:00
commit 93b5c2677c
3 changed files with 219 additions and 0 deletions

View file

@ -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,
)

View file

@ -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)

View file

@ -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,