Record income receipts as a user receivable, not an entity asset

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.
This commit is contained in:
Padreug 2026-05-16 23:40:08 +02:00
commit 0f2a38ee7f
3 changed files with 21 additions and 23 deletions

View file

@ -889,7 +889,7 @@ def format_revenue_entry(
def format_income_entry( def format_income_entry(
user_id: str, user_id: str,
payment_account: str, user_account: str,
revenue_account: str, revenue_account: str,
amount_sats: int, amount_sats: int,
description: str, description: str,
@ -906,7 +906,8 @@ def format_income_entry(
fiat-first price notation (@@ SATS) for BQL queryability, unique link fiat-first price notation (@@ SATS) for BQL queryability, unique link
(^inc-{entry_id}) for tracking through the approve/reject flow. (^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: 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") raise ValueError("fiat_currency and a positive fiat_amount are required for income entries")
@ -921,7 +922,7 @@ def format_income_entry(
postings = [ postings = [
{ {
"account": payment_account, "account": user_account,
"amount": f"{fiat_amount_abs:.2f} {fiat_currency} @@ {sats_abs} SATS", "amount": f"{fiat_amount_abs:.2f} {fiat_currency} @@ {sats_abs} SATS",
}, },
{ {

View file

@ -128,12 +128,18 @@ class RevenueEntry(BaseModel):
class IncomeEntry(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 description: str
amount: Decimal # Fiat amount in the specified currency amount: Decimal # Fiat amount in the specified currency
revenue_account: str # Income/Revenue account name or ID 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.) currency: str # Required: fiat currency code (EUR, USD, etc.)
reference: Optional[str] = None reference: Optional[str] = None
entry_date: Optional[datetime] = None entry_date: Optional[datetime] = None

View file

@ -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})", 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 # Permission check on the revenue account
from .crud import get_user_permissions_with_inheritance 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.", 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 # Convert fiat to sats
fiat_currency = data.currency.upper() fiat_currency = data.currency.upper()
amount_sats = await fiat_amount_as_satoshis(float(data.amount), data.currency) 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( entry = format_income_entry(
user_id=wallet.wallet.user, user_id=wallet.wallet.user,
payment_account=payment_account.name, user_account=user_account.name,
revenue_account=revenue_account.name, revenue_account=revenue_account.name,
amount_sats=amount_sats, amount_sats=amount_sats,
description=data.description, description=data.description,
@ -1323,9 +1314,9 @@ async def api_create_income_entry(
EntryLine( EntryLine(
id=f"line-1-{entry_id}", id=f"line-1-{entry_id}",
journal_entry_id=entry_id, journal_entry_id=entry_id,
account_id=payment_account.id, account_id=user_account.id,
amount=amount_sats, amount=amount_sats,
description=f"Income received into {payment_account.name}", description=f"User holds cash receivable to entity ({user_account.name})",
metadata=metadata, metadata=metadata,
), ),
EntryLine( EntryLine(