From 93b5c2677cbed700577bcf18830db01cb91675e9 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 16 May 2026 19:01:47 +0200 Subject: [PATCH 1/3] 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, -- 2.53.0 From 61952d0015cab47b08cf5334a4647d4dbe591c11 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 16 May 2026 19:55:28 +0200 Subject: [PATCH 2/3] Expose SUBMIT_INCOME in permission management UI Adds the new permission type to the grant/bulk-grant dialog dropdown (static/js/permissions.js) so admins can grant 'Submit Income' on revenue accounts the same way they grant 'Submit Expense' on expense accounts. Without this, the backend's SUBMIT_INCOME check on the new income endpoint is ungranted-able from the UI and users see a 403. Uses 'teal' + the 'payments' icon to distinguish income-grant badges from green-and-add_circle expense-grant badges in the role/account permission lists. Also updates a stale comment in migrations.py listing the valid permission_type values. Co-Authored-By: Claude Opus 4.7 (1M context) --- migrations.py | 2 +- static/js/permissions.js | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/migrations.py b/migrations.py index 3cff47b..9c38c55 100644 --- a/migrations.py +++ b/migrations.py @@ -240,7 +240,7 @@ async def m001_initial(db): # ACCOUNT PERMISSIONS TABLE # ========================================================================= # Granular access control for accounts - # Permission types: read, submit_expense, manage + # Permission types: read, submit_expense, submit_income, manage # Supports hierarchical inheritance (parent account permissions cascade) await db.execute( diff --git a/static/js/permissions.js b/static/js/permissions.js index 948ac3a..cd63797 100644 --- a/static/js/permissions.js +++ b/static/js/permissions.js @@ -53,6 +53,11 @@ window.app = Vue.createApp({ label: 'Submit Expense', description: 'Submit expenses to this account' }, + { + value: 'submit_income', + label: 'Submit Income', + description: 'Submit income/revenue entries to this account' + }, { value: 'manage', label: 'Manage', @@ -501,6 +506,8 @@ window.app = Vue.createApp({ return 'blue' case 'submit_expense': return 'green' + case 'submit_income': + return 'teal' case 'manage': return 'red' default: @@ -514,6 +521,8 @@ window.app = Vue.createApp({ return 'visibility' case 'submit_expense': return 'add_circle' + case 'submit_income': + return 'payments' case 'manage': return 'admin_panel_settings' default: -- 2.53.0 From 0f2a38ee7ff5987a7c0e0aa17d5846d790a13102 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 16 May 2026 23:40:08 +0200 Subject: [PATCH 3/3] Record income receipts as a user receivable, not an entity asset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a user submits income, the money is physically in *their* pocket, not the entity's cash drawer. The original income endpoint posted DR on a configurable payment-method asset account (Cash/Bank/Lightning), which implicitly assumed the entity already had the funds. Mirror the expense flow instead: DR Assets:Receivable:User-{id[:8]} (via get_or_create_user_account), CR the revenue account. The user now owes the entity until they hand the cash over via the existing /settle-receivable workflow. With this, the per-user Outstanding Balances card correctly nets expenses (entity owes user, -liability) against income receipts (user owes entity, +receivable). Drops payment_method_account from IncomeEntry — no longer needed. --- beancount_format.py | 7 ++++--- models.py | 10 ++++++++-- views_api.py | 27 +++++++++------------------ 3 files changed, 21 insertions(+), 23 deletions(-) diff --git a/beancount_format.py b/beancount_format.py index 446ce26..a1bf874 100644 --- a/beancount_format.py +++ b/beancount_format.py @@ -889,7 +889,7 @@ def format_revenue_entry( def format_income_entry( user_id: str, - payment_account: str, + user_account: str, revenue_account: str, amount_sats: int, description: str, @@ -906,7 +906,8 @@ def format_income_entry( 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. + Postings: DR user_account (Assets:Receivable:User-{id} — user owes + the entity until they hand the cash over), 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") @@ -921,7 +922,7 @@ def format_income_entry( postings = [ { - "account": payment_account, + "account": user_account, "amount": f"{fiat_amount_abs:.2f} {fiat_currency} @@ {sats_abs} SATS", }, { diff --git a/models.py b/models.py index 9fe3b9a..25323dd 100644 --- a/models.py +++ b/models.py @@ -128,12 +128,18 @@ class RevenueEntry(BaseModel): class IncomeEntry(BaseModel): - """Helper model for user-facing income/revenue submission (pending approval)""" + """Helper model for user-facing income/revenue submission (pending approval). + + The user records that they personally received money on the entity's + behalf — so the postings are DR Assets:Receivable:User-{id} / CR + revenue_account. The user now owes the entity until they settle via + the existing /settle-receivable flow. Symmetric with ExpenseEntry, + which credits Liabilities:Payable:User-{id} (entity owes user). + """ 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 diff --git a/views_api.py b/views_api.py index f77bea7..dc0dc69 100644 --- a/views_api.py +++ b/views_api.py @@ -1235,21 +1235,6 @@ async def api_create_income_entry( 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 @@ -1262,6 +1247,12 @@ async def api_create_income_entry( detail=f"You do not have permission to submit income to account '{revenue_account.name}'. Please contact an administrator to request access.", ) + # Income lands on the user as a receivable — they're holding cash on + # behalf of the entity until they hand it over via /settle-receivable. + user_account = await get_or_create_user_account( + wallet.wallet.user, AccountType.ASSET, "Accounts Receivable" + ) + # Convert fiat to sats fiat_currency = data.currency.upper() amount_sats = await fiat_amount_as_satoshis(float(data.amount), data.currency) @@ -1288,7 +1279,7 @@ async def api_create_income_entry( entry = format_income_entry( user_id=wallet.wallet.user, - payment_account=payment_account.name, + user_account=user_account.name, revenue_account=revenue_account.name, amount_sats=amount_sats, description=data.description, @@ -1323,9 +1314,9 @@ async def api_create_income_entry( EntryLine( id=f"line-1-{entry_id}", journal_entry_id=entry_id, - account_id=payment_account.id, + account_id=user_account.id, amount=amount_sats, - description=f"Income received into {payment_account.name}", + description=f"User holds cash receivable to entity ({user_account.name})", metadata=metadata, ), EntryLine( -- 2.53.0