Add user-facing income/revenue submission endpoint #13
5 changed files with 227 additions and 1 deletions
|
|
@ -885,3 +885,68 @@ def format_revenue_entry(
|
||||||
links=links,
|
links=links,
|
||||||
meta=entry_meta
|
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,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -240,7 +240,7 @@ async def m001_initial(db):
|
||||||
# ACCOUNT PERMISSIONS TABLE
|
# ACCOUNT PERMISSIONS TABLE
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Granular access control for accounts
|
# 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)
|
# Supports hierarchical inheritance (parent account permissions cascade)
|
||||||
|
|
||||||
await db.execute(
|
await db.execute(
|
||||||
|
|
|
||||||
19
models.py
19
models.py
|
|
@ -127,6 +127,24 @@ class RevenueEntry(BaseModel):
|
||||||
currency: Optional[str] = None # If None, amount is in satoshis. Otherwise, fiat currency code
|
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):
|
class LibraSettings(BaseModel):
|
||||||
"""Settings for the Libra extension"""
|
"""Settings for the Libra extension"""
|
||||||
|
|
||||||
|
|
@ -295,6 +313,7 @@ class PermissionType(str, Enum):
|
||||||
"""Types of permissions for account access"""
|
"""Types of permissions for account access"""
|
||||||
READ = "read" # Can view account and its balance
|
READ = "read" # Can view account and its balance
|
||||||
SUBMIT_EXPENSE = "submit_expense" # Can submit expenses to this account
|
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)
|
MANAGE = "manage" # Can modify account (admin level)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,11 @@ window.app = Vue.createApp({
|
||||||
label: 'Submit Expense',
|
label: 'Submit Expense',
|
||||||
description: 'Submit expenses to this account'
|
description: 'Submit expenses to this account'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
value: 'submit_income',
|
||||||
|
label: 'Submit Income',
|
||||||
|
description: 'Submit income/revenue entries to this account'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
value: 'manage',
|
value: 'manage',
|
||||||
label: 'Manage',
|
label: 'Manage',
|
||||||
|
|
@ -501,6 +506,8 @@ window.app = Vue.createApp({
|
||||||
return 'blue'
|
return 'blue'
|
||||||
case 'submit_expense':
|
case 'submit_expense':
|
||||||
return 'green'
|
return 'green'
|
||||||
|
case 'submit_income':
|
||||||
|
return 'teal'
|
||||||
case 'manage':
|
case 'manage':
|
||||||
return 'red'
|
return 'red'
|
||||||
default:
|
default:
|
||||||
|
|
@ -514,6 +521,8 @@ window.app = Vue.createApp({
|
||||||
return 'visibility'
|
return 'visibility'
|
||||||
case 'submit_expense':
|
case 'submit_expense':
|
||||||
return 'add_circle'
|
return 'add_circle'
|
||||||
|
case 'submit_income':
|
||||||
|
return 'payments'
|
||||||
case 'manage':
|
case 'manage':
|
||||||
return 'admin_panel_settings'
|
return 'admin_panel_settings'
|
||||||
default:
|
default:
|
||||||
|
|
|
||||||
133
views_api.py
133
views_api.py
|
|
@ -62,6 +62,7 @@ from .models import (
|
||||||
CreateUserEquityStatus,
|
CreateUserEquityStatus,
|
||||||
ExpenseEntry,
|
ExpenseEntry,
|
||||||
GeneratePaymentInvoice,
|
GeneratePaymentInvoice,
|
||||||
|
IncomeEntry,
|
||||||
JournalEntry,
|
JournalEntry,
|
||||||
JournalEntryFlag,
|
JournalEntryFlag,
|
||||||
ManualPaymentRequest,
|
ManualPaymentRequest,
|
||||||
|
|
@ -1198,6 +1199,138 @@ 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)
|
@libra_api_router.post("/api/v1/entries/receivable", status_code=HTTPStatus.CREATED)
|
||||||
async def api_create_receivable_entry(
|
async def api_create_receivable_entry(
|
||||||
data: ReceivableEntry,
|
data: ReceivableEntry,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue