diff --git a/beancount_format.py b/beancount_format.py index a1bf874..956a4ee 100644 --- a/beancount_format.py +++ b/beancount_format.py @@ -885,68 +885,3 @@ def format_revenue_entry( links=links, meta=entry_meta ) - - -def format_income_entry( - user_id: str, - user_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 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") - - 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": user_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/migrations.py b/migrations.py index 9c38c55..3cff47b 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, submit_income, manage + # Permission types: read, submit_expense, manage # Supports hierarchical inheritance (parent account permissions cascade) await db.execute( diff --git a/models.py b/models.py index 25323dd..eaf75c4 100644 --- a/models.py +++ b/models.py @@ -127,24 +127,6 @@ 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). - - 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 - 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""" @@ -313,7 +295,6 @@ 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/static/js/permissions.js b/static/js/permissions.js index cd63797..948ac3a 100644 --- a/static/js/permissions.js +++ b/static/js/permissions.js @@ -53,11 +53,6 @@ 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', @@ -506,8 +501,6 @@ window.app = Vue.createApp({ return 'blue' case 'submit_expense': return 'green' - case 'submit_income': - return 'teal' case 'manage': return 'red' default: @@ -521,8 +514,6 @@ window.app = Vue.createApp({ return 'visibility' case 'submit_expense': return 'add_circle' - case 'submit_income': - return 'payments' case 'manage': return 'admin_panel_settings' default: diff --git a/views_api.py b/views_api.py index dc0dc69..8c22f96 100644 --- a/views_api.py +++ b/views_api.py @@ -62,7 +62,6 @@ from .models import ( CreateUserEquityStatus, ExpenseEntry, GeneratePaymentInvoice, - IncomeEntry, JournalEntry, JournalEntryFlag, ManualPaymentRequest, @@ -1199,138 +1198,6 @@ 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})", - ) - - # 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.", - ) - - # 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) - - 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, - user_account=user_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=user_account.id, - amount=amount_sats, - description=f"User holds cash receivable to entity ({user_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,