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:
parent
9e06fa0b2a
commit
a7d7740a3a
12 changed files with 4233 additions and 4130 deletions
979
views_api/payments.py
Normal file
979
views_api/payments.py
Normal 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 =====
|
||||
Loading…
Add table
Add a link
Reference in a new issue