refactor(api): split views_api.py into a package of domain modules

Pure move — no logic changes. The 4,100-line single file becomes
views_api/ with one module per domain (accounts, entries, payments,
settings_reports, reconciliation, permissions, admin), each
registering full literal paths on its own APIRouter; __init__ builds
the combined libra_api_router so libra/__init__.py is untouched.
Shared imports/helpers live in views_api/_shared.py;
_extract_entry_id and _SYSTEM_LINK_PREFIXES move to
beancount_format.py (they are pure entry-dict parsing).

Route behavior is pinned by tests/test_route_table.py: the 73-route
set is unchanged, and the two order-sensitive families (the shadowed
/accounts/hierarchy wart — deliberately preserved here, fixed in the
next commit — and the admin sync literal/param pair) keep their
relative order inside a single module each.

Addresses CODE-REVIEW-2026-06 structure findings (views_api monolith).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-07-12 16:09:58 +02:00
commit a7d7740a3a
12 changed files with 4233 additions and 4130 deletions

979
views_api/payments.py Normal file
View file

@ -0,0 +1,979 @@
"""Libra API — payments endpoints (moved verbatim from views_api.py)."""
from fastapi import APIRouter
from ._shared import * # noqa: F401,F403
router = APIRouter()
@router.get("/api/v1/balance")
async def api_get_my_balance(
wallet: WalletTypeInfo = Depends(require_invoice_key),
) -> UserBalance:
"""Get current user's balance with the organization (from Fava/Beancount)"""
from lnbits.settings import settings as lnbits_settings
from ..fava_client import get_fava_client
fava = get_fava_client()
# If super user, show total libra position
if wallet.wallet.user == lnbits_settings.super_user:
all_balances = await fava.get_all_user_balances_bql()
# Calculate total:
# From get_user_balance_bql(): positive = user owes libra, negative = libra owes user
# Positive balances = Users owe Libra (receivables for Libra)
# Negative balances = Libra owes users (liabilities for Libra)
# Net: positive means libra is owed money, negative means libra owes money
total_receivables = sum(b["balance"] for b in all_balances if b["balance"] > 0)
total_liabilities = sum(abs(b["balance"]) for b in all_balances if b["balance"] < 0)
net_balance = total_receivables - total_liabilities
# Aggregate fiat balances from all users
total_fiat_balances = {}
for user_balance in all_balances:
for currency, amount in user_balance["fiat_balances"].items():
if currency not in total_fiat_balances:
total_fiat_balances[currency] = Decimal("0")
# Add all balances (positive and negative)
total_fiat_balances[currency] += amount
# Super-user totals reflect their personal submissions (if any), not org-wide
super_totals = await fava.get_user_lifetime_totals_bql(wallet.wallet.user)
# Return net position
return UserBalance(
user_id=wallet.wallet.user,
balance=net_balance,
accounts=[],
fiat_balances=total_fiat_balances,
total_expenses_sats=super_totals["total_expenses_sats"],
total_expenses_fiat=super_totals["total_expenses_fiat"],
total_income_sats=super_totals["total_income_sats"],
total_income_fiat=super_totals["total_income_fiat"],
)
# For regular users, show their individual balance from Fava
balance_data = await fava.get_user_balance_bql(wallet.wallet.user)
totals = await fava.get_user_lifetime_totals_bql(wallet.wallet.user)
return UserBalance(
user_id=wallet.wallet.user,
balance=balance_data["balance"],
accounts=[],
account_balances=balance_data.get("accounts", []),
fiat_balances=balance_data["fiat_balances"],
total_expenses_sats=totals["total_expenses_sats"],
total_expenses_fiat=totals["total_expenses_fiat"],
total_income_sats=totals["total_income_sats"],
total_income_fiat=totals["total_income_fiat"],
)
@router.get("/api/v1/balance/{user_id}")
async def api_get_user_balance(
user_id: str,
auth: AuthContext = Depends(require_authenticated),
) -> UserBalance:
"""
Get a specific user's balance with the organization (from Fava/Beancount).
Users can only access their own balance. Super users can access any user's balance.
"""
# Check access: must be own data or super user
await require_user_data_access(auth, user_id)
from ..fava_client import get_fava_client
fava = get_fava_client()
balance_data = await fava.get_user_balance_bql(user_id)
totals = await fava.get_user_lifetime_totals_bql(user_id)
return UserBalance(
user_id=user_id,
balance=balance_data["balance"],
accounts=[],
fiat_balances=balance_data["fiat_balances"],
total_expenses_sats=totals["total_expenses_sats"],
total_expenses_fiat=totals["total_expenses_fiat"],
total_income_sats=totals["total_income_sats"],
total_income_fiat=totals["total_income_fiat"],
)
@router.get("/api/v1/balances/all")
async def api_get_all_balances(
auth: AuthContext = Depends(require_super_user),
) -> list[dict]:
"""Get all user balances (super user only) from Fava/Beancount"""
from ..fava_client import get_fava_client
fava = get_fava_client()
balances = await fava.get_all_user_balances_bql()
# Enrich with username information using helper function
result = []
for balance in balances:
username = await get_username(balance["user_id"])
result.append({
"user_id": balance["user_id"],
"username": username,
"balance": balance["balance"],
"fiat_balances": balance["fiat_balances"],
"accounts": balance["accounts"],
})
return result
# ===== PAYMENT ENDPOINTS =====
@router.post("/api/v1/generate-payment-invoice")
async def api_generate_payment_invoice(
data: GeneratePaymentInvoice,
wallet: WalletTypeInfo = Depends(require_invoice_key),
) -> dict:
"""
Generate an invoice on the Libra wallet for user to pay their balance.
User can then pay this invoice to settle their debt.
If user_id is provided (admin only), the invoice is generated for that specific user.
"""
from lnbits.core.crud.wallets import get_wallet
from lnbits.core.models import CreateInvoice
from lnbits.core.services import create_payment_request
from lnbits.settings import settings as lnbits_settings
# Determine which user this invoice is for
if data.user_id:
# Admin generating invoice for a specific user
if wallet.wallet.user != lnbits_settings.super_user:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="Only super user can generate invoices for other users",
)
target_user_id = data.user_id
else:
# User generating invoice for themselves
target_user_id = wallet.wallet.user
# Get libra wallet ID
libra_wallet_id = await check_libra_wallet_configured()
# Get user's balance from Fava to calculate fiat metadata
from ..fava_client import get_fava_client
fava = get_fava_client()
balance_data = await fava.get_user_balance_bql(target_user_id)
# Build UserBalance object for compatibility
user_balance = UserBalance(
user_id=target_user_id,
balance=balance_data["balance"],
accounts=[],
fiat_balances=balance_data["fiat_balances"]
)
# Calculate proportional fiat amount for this invoice
invoice_extra = {"tag": "libra", "user_id": target_user_id}
logger.info(f"User balance for invoice generation - sats: {user_balance.balance}, fiat_balances: {user_balance.fiat_balances}")
if user_balance.fiat_balances:
# Simple single-currency solution: use the first (and should be only) currency
currencies = list(user_balance.fiat_balances.keys())
if len(currencies) > 1:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"User has multiple currencies ({', '.join(currencies)}). Please settle to a single currency first.",
)
if len(currencies) == 1:
fiat_currency = currencies[0]
total_fiat_balance = user_balance.fiat_balances[fiat_currency]
total_sat_balance = abs(user_balance.balance) # Use absolute value
if total_sat_balance > 0:
# Calculate proportional fiat amount for this invoice
# fiat_amount = (invoice_amount / total_sats) * total_fiat
from decimal import Decimal
proportion = Decimal(data.amount) / Decimal(total_sat_balance)
invoice_fiat_amount = abs(total_fiat_balance) * proportion
invoice_extra.update({
"fiat_currency": fiat_currency,
"fiat_amount": str(invoice_fiat_amount.quantize(Decimal("0.001"))),
**fiat_rate_metadata(data.amount, invoice_fiat_amount),
})
logger.info(f"Invoice extra metadata: {invoice_extra}")
# Create invoice on libra wallet
invoice_data = CreateInvoice(
out=False,
amount=data.amount,
memo=f"Payment from user {target_user_id[:8]} to Libra",
unit="sat",
extra=invoice_extra,
)
payment = await create_payment_request(libra_wallet_id, invoice_data)
# Get libra wallet to return its inkey for payment checking
libra_wallet = await get_wallet(libra_wallet_id)
if not libra_wallet:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Libra wallet not found"
)
return {
"payment_hash": payment.payment_hash,
"payment_request": payment.bolt11,
"amount": data.amount,
"memo": invoice_data.memo,
"check_wallet_key": libra_wallet.inkey, # Key to check payment status
}
@router.post("/api/v1/record-payment")
async def api_record_payment(
data: RecordPayment,
wallet: WalletTypeInfo = Depends(require_invoice_key),
) -> dict:
"""
Record a lightning payment in accounting after invoice is paid.
This reduces what the user owes to the libra.
The user_id is extracted from the payment metadata (set during invoice generation).
"""
from lnbits.core.crud.payments import get_standalone_payment
# Get the payment details (incoming=True to get the invoice, not the payment)
payment = await get_standalone_payment(data.payment_hash, incoming=True)
if not payment:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Payment not found"
)
if payment.pending:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail="Payment not yet settled"
)
# Get user_id from payment metadata (set during invoice generation)
target_user_id = None
if payment.extra and isinstance(payment.extra, dict):
target_user_id = payment.extra.get("user_id")
if not target_user_id:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Payment metadata missing user_id. Cannot determine which user to credit.",
)
# Check if payment already recorded in Fava (idempotency)
from ..fava_client import get_fava_client
from ..beancount_format import format_payment_entry
import httpx
fava = get_fava_client()
# Check if payment already recorded by fetching recent entries
# Note: We can't use BQL query with `links ~ 'pattern'` because links is a set type
# and BQL doesn't support regex matching on sets. Instead, fetch entries and filter in Python.
link_to_find = f"ln-{data.payment_hash[:16]}"
try:
async with httpx.AsyncClient(timeout=5.0) as client:
# Get recent entries from Fava's journal endpoint. base_url
# already ends in /api — the previous "/api/journal" path
# 404'd, so this duplicate check silently never ran.
response = await client.get(
f"{fava.base_url}/journal",
params={"time": ""} # Get all entries
)
response.raise_for_status()
response_data = response.json()
entries = response_data.get('entries', [])
# Check if any entry has our payment link
for entry in entries:
entry_links = entry.get('links', [])
if link_to_find in entry_links:
# Payment already recorded, return existing entry
balance_data = await fava.get_user_balance_bql(target_user_id)
return {
"journal_entry_id": f"fava-exists-{data.payment_hash[:16]}",
"new_balance": balance_data["balance"],
"message": "Payment already recorded",
}
except httpx.HTTPError as e:
# Fail CLOSED: if Fava can't confirm the payment isn't already
# recorded, refuse to write — proceeding on a transient blip is
# how double entries happen. The client can simply retry.
logger.warning(f"Could not check Fava for duplicate payment: {e}")
raise HTTPException(
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
detail="Cannot verify payment duplicate status; try again shortly",
)
# Local idempotency gate shared with the background invoice listener
# (tasks.on_invoice_paid): exactly one claimant records a payment_hash.
from ..crud import (
claim_payment,
get_processed_payment,
mark_payment_done,
release_payment_claim,
)
if not await claim_payment(data.payment_hash):
existing = await get_processed_payment(data.payment_hash)
if existing and existing["status"] == "done":
balance_data = await fava.get_user_balance_bql(target_user_id)
return {
"journal_entry_id": existing.get("entry_id")
or f"fava-exists-{data.payment_hash[:16]}",
"new_balance": balance_data["balance"],
"message": "Payment already recorded",
}
raise HTTPException(
status_code=HTTPStatus.CONFLICT,
detail="Payment is being recorded; check balance shortly",
)
# Convert amount from millisatoshis to satoshis
try:
amount_sats = payment.amount // 1000
# Extract fiat metadata from invoice (if present)
fiat_currency = None
fiat_amount = None
if payment.extra and isinstance(payment.extra, dict):
logger.info(f"Payment.extra contents: {payment.extra}")
fiat_currency = payment.extra.get("fiat_currency")
fiat_amount_str = payment.extra.get("fiat_amount")
if fiat_amount_str:
from decimal import Decimal
fiat_amount = Decimal(str(fiat_amount_str))
logger.info(f"Extracted fiat metadata - currency: {fiat_currency}, amount: {fiat_amount}")
# Get user's receivable account (what user owes)
user_receivable = await get_or_create_user_account(
target_user_id, AccountType.ASSET, "Accounts Receivable"
)
# Get lightning account
lightning_account = await get_account_by_name("Assets:Bitcoin:Lightning")
if not lightning_account:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Lightning account not found"
)
# Get unsettled receivable entries to link to this settlement
unsettled = await fava.get_unsettled_entries_bql(target_user_id, "receivable")
settled_links = [e["link"] for e in unsettled if e.get("link")]
# Format payment entry and submit to Fava
entry = format_payment_entry(
user_id=target_user_id,
payment_account=lightning_account.name,
payable_or_receivable_account=user_receivable.name,
amount_sats=amount_sats,
description=f"Lightning payment from user {target_user_id[:8]}",
entry_date=datetime.now().date(),
is_payable=False, # User paying libra (receivable settlement)
fiat_currency=fiat_currency,
fiat_amount=fiat_amount,
payment_hash=data.payment_hash,
reference=data.payment_hash,
settled_entry_links=settled_links
)
logger.info(f"Formatted payment entry: {entry}")
# Submit to Fava
result = await fava.add_entry(entry)
logger.info(f"Payment entry submitted to Fava: {result.get('data', 'Unknown')}")
entry_id = f"ln-{data.payment_hash[:16]}"
await mark_payment_done(data.payment_hash, entry_id)
except BaseException:
# Release the claim so a retry (client or background listener)
# can record this payment.
await release_payment_claim(data.payment_hash)
raise
# Get updated balance from Fava
balance_data = await fava.get_user_balance_bql(target_user_id)
return {
"journal_entry_id": entry_id,
"new_balance": balance_data["balance"],
"message": "Payment recorded successfully",
}
@router.post("/api/v1/receivables/settle")
async def api_settle_receivable(
data: SettleReceivable,
auth: AuthContext = Depends(require_super_user),
) -> dict:
"""
Manually settle a receivable (record when user pays libra in person).
This endpoint is for non-lightning payments like:
- Cash payments
- Bank transfers
- Other manual settlements
Super user only.
"""
# Validate payment method
valid_methods = ["cash", "bank_transfer", "check", "lightning", "btc_onchain", "other"]
if data.payment_method.lower() not in valid_methods:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"Invalid payment method. Must be one of: {', '.join(valid_methods)}",
)
# Get user's receivable account (what user owes)
user_receivable = await get_or_create_user_account(
data.user_id, AccountType.ASSET, "Accounts Receivable"
)
# Get the appropriate asset account based on payment method
payment_account_map = {
"cash": "Assets:Cash",
"bank_transfer": "Assets:Bank",
"check": "Assets:Bank",
"lightning": "Assets:Bitcoin:Lightning",
"btc_onchain": "Assets:Bitcoin:OnChain",
"other": "Assets:Cash"
}
account_name = payment_account_map.get(data.payment_method.lower(), "Assets:Cash")
payment_account = await get_account_by_name(account_name)
# If account doesn't exist, try to find or create a generic one
if not payment_account:
# Try to find any asset account that's not receivable
all_accounts = await get_all_accounts()
for acc in all_accounts:
if acc.account_type == AccountType.ASSET and "receivable" not in acc.name.lower():
payment_account = acc
break
if not payment_account:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail=f"Payment account '{account_name}' not found. Please create it first.",
)
# Format settlement entry and submit to Fava
# DR Cash/Bank (asset increased), CR Accounts Receivable (asset decreased)
# This records that user paid their debt
from ..fava_client import get_fava_client
from ..beancount_format import (
format_payment_entry,
format_fiat_settlement_entry,
format_fiat_net_settlement_entry,
)
from decimal import Decimal
fava = get_fava_client()
# Determine if this is a fiat or lightning payment
is_fiat_payment = data.currency and data.payment_method.lower() in [
"cash", "bank_transfer", "check", "other"
]
if is_fiat_payment and data.settled_entry_links is None:
# Auto-detect netting + credit-overflow path (libra-#33 + libra-#41).
# The operator hasn't picked specific entries — backend nets all
# open balances in both directions, validates cash matches the net
# obligation (or absorbs excess into credit), and writes a single
# transaction that links every reconciled source entry.
unsettled_payables = await fava.get_unsettled_entries_bql(data.user_id, "expense")
unsettled_receivables = await fava.get_unsettled_entries_bql(data.user_id, "receivable")
# Net only entries denominated in the settlement currency — summing
# mixed currencies as if they were one silently mis-states the net
# obligation and links entries this settlement doesn't actually clear.
settle_currency = data.currency.upper()
unsettled_payables = [
e for e in unsettled_payables
if e.get("fiat_currency") == settle_currency
]
unsettled_receivables = [
e for e in unsettled_receivables
if e.get("fiat_currency") == settle_currency
]
payable_total = sum(
(Decimal(str(e["fiat_amount"])) for e in unsettled_payables),
Decimal(0),
)
receivable_total = sum(
(Decimal(str(e["fiat_amount"])) for e in unsettled_receivables),
Decimal(0),
)
all_links = (
[e["link"] for e in unsettled_payables if e.get("link")]
+ [e["link"] for e in unsettled_receivables if e.get("link")]
)
if receivable_total <= 0:
# Endpoint is `/receivables/settle` — user paying off something
# they owe. With no open receivable there's nothing this endpoint
# can settle. Operator should use `/payables/pay` (libra pays user)
# or wait until the user has open receivables.
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=(
f"User {data.user_id[:8]} has no open receivables to settle. "
f"If libra owes them, use `/payables/pay`. If they want to "
f"deposit credit without an open obligation, that's a future "
f"feature (libra-#41 follow-up)."
),
)
cash_paid = Decimal(str(data.amount))
net_obligation = receivable_total - payable_total
tolerance = Decimal("0.01") # forex rounding slack
if cash_paid + tolerance < net_obligation:
# Under-pay without explicit entry-picks — backend can't guess
# which receivable(s) the operator means to settle.
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail={
"message": (
"Cash paid is less than net obligation. Pay the exact "
"net to clear all open entries, or pass "
"`settled_entry_links` to settle a specific subset."
),
"cash_paid": str(cash_paid),
"net_obligation": str(net_obligation),
"receivable_total": str(receivable_total),
"payable_total": str(payable_total),
"currency": data.currency.upper(),
},
)
credit_overflow = cash_paid - net_obligation
if credit_overflow < tolerance:
credit_overflow = Decimal(0)
# Auto-create the user-side accounts as needed.
user_payable = None
if payable_total > 0:
user_payable = await get_or_create_user_account(
data.user_id, AccountType.LIABILITY, "Accounts Payable",
)
user_credit = None
if credit_overflow > 0:
user_credit = await get_or_create_user_account(
data.user_id, AccountType.LIABILITY, "Credit",
)
entry = format_fiat_net_settlement_entry(
user_id=data.user_id,
cash_account=payment_account.name,
receivable_account=user_receivable.name,
payable_account=user_payable.name if user_payable else None,
credit_account=user_credit.name if user_credit else None,
cash_paid_fiat=cash_paid,
total_receivable_fiat=receivable_total,
total_payable_fiat=payable_total,
credit_overflow_fiat=credit_overflow,
fiat_currency=data.currency.upper(),
description=data.description,
entry_date=datetime.now().date(),
payment_method=data.payment_method,
reference=data.reference or f"MANUAL-{data.user_id[:8]}",
settled_entry_links=all_links,
)
elif is_fiat_payment:
# Legacy fiat path — operator provided `settled_entry_links` explicitly,
# meaning they're settling a specific subset. Backwards-compatible
# 2-leg behaviour: trust the caller's list, no auto-netting, no
# credit-overflow validation. Use the auto-detect path above (omit
# settled_entry_links) to get netting + credit handling.
if not data.amount_sats:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="amount_sats is required when settling with fiat currency"
)
entry = format_fiat_settlement_entry(
user_id=data.user_id,
payment_account=payment_account.name,
payable_or_receivable_account=user_receivable.name,
fiat_amount=Decimal(str(data.amount)),
fiat_currency=data.currency.upper(),
amount_sats=data.amount_sats,
description=data.description,
entry_date=datetime.now().date(),
is_payable=False, # User paying libra (receivable settlement)
payment_method=data.payment_method,
reference=data.reference or f"MANUAL-{data.user_id[:8]}",
settled_entry_links=data.settled_entry_links
)
else:
# Lightning or BTC onchain payment
# Record in SATS with optional fiat metadata
amount_in_sats = data.amount_sats if data.amount_sats else int(data.amount)
fiat_currency = data.currency.upper() if data.currency else None
fiat_amount = Decimal(str(data.amount)) if data.currency else None
# Get settled entry links (use provided or auto-query unsettled)
settled_links = data.settled_entry_links
if not settled_links:
unsettled = await fava.get_unsettled_entries_bql(data.user_id, "receivable")
settled_links = [e["link"] for e in unsettled if e.get("link")]
entry = format_payment_entry(
user_id=data.user_id,
payment_account=payment_account.name,
payable_or_receivable_account=user_receivable.name,
amount_sats=amount_in_sats,
description=data.description,
entry_date=datetime.now().date(),
is_payable=False, # User paying libra (receivable settlement)
fiat_currency=fiat_currency,
fiat_amount=fiat_amount,
payment_hash=data.payment_hash,
reference=data.reference or f"MANUAL-{data.user_id[:8]}",
settled_entry_links=settled_links
)
# Add additional metadata to entry
if "meta" not in entry:
entry["meta"] = {}
entry["meta"]["payment-method"] = data.payment_method
entry["meta"]["settled-by"] = auth.user_id
if data.txid:
entry["meta"]["txid"] = data.txid
# Submit to Fava
result = await fava.add_entry(entry)
logger.info(f"Receivable settlement submitted to Fava: {result.get('data', 'Unknown')}")
# Get updated balance from Fava
balance_data = await fava.get_user_balance_bql(data.user_id)
return {
"journal_entry_id": f"fava-{datetime.now().timestamp()}",
"user_id": data.user_id,
"amount_settled": float(data.amount),
"currency": data.currency,
"payment_method": data.payment_method,
"new_balance": balance_data["balance"],
"message": f"Receivable settled successfully via {data.payment_method}",
}
@router.post("/api/v1/payables/pay")
async def api_pay_user(
data: PayUser,
auth: AuthContext = Depends(require_super_user),
) -> dict:
"""
Pay a user (libra pays user for expense/liability).
This endpoint is for both lightning and manual payments:
- Lightning payments: already executed, just record the payment
- Cash/Bank/Check: record manual payment that was made
Super user only.
"""
# Validate payment method
valid_methods = ["cash", "bank_transfer", "check", "lightning", "btc_onchain", "other"]
if data.payment_method.lower() not in valid_methods:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"Invalid payment method. Must be one of: {', '.join(valid_methods)}",
)
# Get user's payable account (what libra owes)
user_payable = await get_or_create_user_account(
data.user_id, AccountType.LIABILITY, "Accounts Payable"
)
# Get the appropriate asset account based on payment method
payment_account_map = {
"cash": "Assets:Cash",
"bank_transfer": "Assets:Bank",
"check": "Assets:Bank",
"lightning": "Assets:Bitcoin:Lightning",
"btc_onchain": "Assets:Bitcoin:OnChain",
"other": "Assets:Cash"
}
account_name = payment_account_map.get(data.payment_method.lower(), "Assets:Cash")
payment_account = await get_account_by_name(account_name)
if not payment_account:
# Try to find any asset account that's not receivable
all_accounts = await get_all_accounts()
for acc in all_accounts:
if acc.account_type == AccountType.ASSET and "receivable" not in acc.name.lower():
payment_account = acc
break
if not payment_account:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail=f"Payment account '{account_name}' not found. Please create it first.",
)
# Format payment entry and submit to Fava
# DR Accounts Payable (liability decreased), CR Cash/Lightning/Bank (asset decreased)
# This records that libra paid its debt
from ..fava_client import get_fava_client
from ..beancount_format import format_payment_entry, format_fiat_settlement_entry
from decimal import Decimal
fava = get_fava_client()
# Determine if this is a fiat payment that should use format_fiat_settlement_entry
is_fiat_payment = data.currency and data.payment_method.lower() in [
"cash", "bank_transfer", "check", "other"
]
if is_fiat_payment:
# Fiat currency payment (cash, bank transfer, etc.)
if not data.amount_sats:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="amount_sats is required when paying with fiat currency"
)
entry = format_fiat_settlement_entry(
user_id=data.user_id,
payment_account=payment_account.name,
payable_or_receivable_account=user_payable.name,
fiat_amount=Decimal(str(data.amount)),
fiat_currency=data.currency.upper(),
amount_sats=data.amount_sats,
description=data.description or f"Payment to user via {data.payment_method}",
entry_date=datetime.now().date(),
is_payable=True, # Libra paying user (payable settlement)
payment_method=data.payment_method,
reference=data.reference or f"PAY-{data.user_id[:8]}",
settled_entry_links=data.settled_entry_links
)
else:
# Lightning or BTC onchain payment
if data.currency:
if not data.amount_sats:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="amount_sats is required when paying with fiat currency"
)
amount_in_sats = data.amount_sats
fiat_currency = data.currency.upper()
fiat_amount = data.amount
else:
amount_in_sats = int(data.amount)
fiat_currency = None
fiat_amount = None
# Get settled entry links (use provided or auto-query unsettled)
settled_links = data.settled_entry_links
if not settled_links:
unsettled = await fava.get_unsettled_entries_bql(data.user_id, "expense")
settled_links = [e["link"] for e in unsettled if e.get("link")]
entry = format_payment_entry(
user_id=data.user_id,
payment_account=payment_account.name,
payable_or_receivable_account=user_payable.name,
amount_sats=amount_in_sats,
description=data.description or f"Payment to user via {data.payment_method}",
entry_date=datetime.now().date(),
is_payable=True, # Libra paying user (payable settlement)
fiat_currency=fiat_currency,
fiat_amount=fiat_amount,
payment_hash=data.payment_hash,
reference=data.reference or f"PAY-{data.user_id[:8]}",
settled_entry_links=settled_links
)
# Add additional metadata to entry
if "meta" not in entry:
entry["meta"] = {}
entry["meta"]["payment-method"] = data.payment_method
entry["meta"]["paid-by"] = auth.user_id
if data.txid:
entry["meta"]["txid"] = data.txid
# Submit to Fava
result = await fava.add_entry(entry)
logger.info(f"Payable payment submitted to Fava: {result.get('data', 'Unknown')}")
# Get updated balance from Fava
balance_data = await fava.get_user_balance_bql(data.user_id)
return {
"journal_entry_id": f"fava-{datetime.now().timestamp()}",
"user_id": data.user_id,
"amount_paid": float(data.amount),
"currency": data.currency,
"payment_method": data.payment_method,
"new_balance": balance_data["balance"],
"message": f"User paid successfully via {data.payment_method}",
}
# ===== SETTINGS ENDPOINTS =====
@router.post("/api/v1/manual-payment-request")
async def api_create_manual_payment_request(
data: CreateManualPaymentRequest,
wallet: WalletTypeInfo = Depends(require_invoice_key),
) -> ManualPaymentRequest:
"""Create a manual payment request for an admin to review"""
return await create_manual_payment_request(
wallet.wallet.user, data.amount, data.description
)
@router.get("/api/v1/manual-payment-requests")
async def api_get_manual_payment_requests(
wallet: WalletTypeInfo = Depends(require_invoice_key),
) -> list[ManualPaymentRequest]:
"""Get manual payment requests for the current user"""
return await get_user_manual_payment_requests(wallet.wallet.user)
@router.get("/api/v1/manual-payment-requests/all")
async def api_get_all_manual_payment_requests(
status: str = None,
auth: AuthContext = Depends(require_super_user),
) -> list[ManualPaymentRequest]:
"""Get all manual payment requests (super user only)"""
return await get_all_manual_payment_requests(status)
@router.post("/api/v1/manual-payment-requests/{request_id}/approve")
async def api_approve_manual_payment_request(
request_id: str,
auth: AuthContext = Depends(require_super_user),
) -> ManualPaymentRequest:
"""Approve a manual payment request and create accounting entry (super user only)"""
# Get the request
request = await get_manual_payment_request(request_id)
if not request:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Manual payment request not found",
)
if request.status != "pending":
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"Request already {request.status}",
)
# Get libra wallet from settings
libra_wallet_id = await check_libra_wallet_configured()
# Get or create liability account for user (libra owes the user)
liability_account = await get_or_create_user_account(
request.user_id, AccountType.LIABILITY, "Accounts Payable"
)
# Get the Lightning asset account
lightning_account = await get_account_by_name("Assets:Bitcoin:Lightning")
if not lightning_account:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Lightning account not found",
)
# Format payment entry and submit to Fava
from ..fava_client import get_fava_client
from ..beancount_format import format_payment_entry
fava = get_fava_client()
# Get unsettled expense entries to link to this settlement
unsettled = await fava.get_unsettled_entries_bql(request.user_id, "expense")
settled_links = [e["link"] for e in unsettled if e.get("link")]
entry = format_payment_entry(
user_id=request.user_id,
payment_account=lightning_account.name,
payable_or_receivable_account=liability_account.name,
amount_sats=request.amount,
description=f"Manual payment to user: {request.description}",
entry_date=datetime.now().date(),
is_payable=True, # Libra paying user
reference=f"MPR-{request.id}",
settled_entry_links=settled_links
)
# Claim the request BEFORE writing the ledger entry — the
# status-guarded UPDATE makes exactly one concurrent admin win, so
# only one journal entry can ever be created for this request.
approved = await approve_manual_payment_request(
request_id, auth.user_id, f"MPR-{request.id}"
)
if approved is None:
raise HTTPException(
status_code=HTTPStatus.CONFLICT,
detail="Request was already reviewed by another admin",
)
try:
result = await fava.add_entry(entry)
logger.info(f"Manual payment entry submitted to Fava: {result.get('data', 'Unknown')}")
except BaseException:
# Ledger write failed — release the claim so the request can be
# approved again.
from ..crud import revert_manual_payment_request
await revert_manual_payment_request(request_id)
raise
return approved
@router.post("/api/v1/manual-payment-requests/{request_id}/reject")
async def api_reject_manual_payment_request(
request_id: str,
auth: AuthContext = Depends(require_super_user),
) -> ManualPaymentRequest:
"""Reject a manual payment request (super user only)"""
# Get the request
request = await get_manual_payment_request(request_id)
if not request:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Manual payment request not found",
)
if request.status != "pending":
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"Request already {request.status}",
)
rejected = await reject_manual_payment_request(request_id, auth.user_id)
if rejected is None:
raise HTTPException(
status_code=HTTPStatus.CONFLICT,
detail="Request was already reviewed by another admin",
)
return rejected
# ===== EXPENSE APPROVAL ENDPOINTS =====