fix(settlement): per-currency netting, balance guards, Decimal rates

Settlement correctness cluster from CODE-REVIEW-2026-06 (#2, #8, #13)
plus libra-#38:

- format_net_settlement_entry now enforces the same inline balance
  constraint as the fiat formatter (payment = receivable - payable
  + credit) and grows an optional credit leg. An unbalanced
  settlement raises instead of reaching the ledger.
- on_invoice_paid settles only what the payment covers: a partial
  payment clears that much receivable; excess (or a payment with
  nothing owed) becomes user credit. Previously the full prior
  balance was cleared against a smaller payment, shipping unbalanced
  postings. Settlement links are attached only when the payment
  clears the full open balance, and only for same-currency entries.
- get_unsettled_entries_bql returns each entry's real posting
  currency (was hardcoded "EUR") and exact Decimal amount strings
  (was float). /receivables/settle nets only entries denominated in
  the settlement currency.
- fiat_rate/btc_rate metadata computed via Decimal (new
  fiat_rate_metadata helper) instead of float division — cost-basis
  records no longer carry float drift.
- format_posting_at_average_cost omits the cost braces when
  cost_currency is unset ("SATS {}" is invalid Beancount).
- Underpay error payload serializes amounts as exact Decimal strings.
- validate_metadata catches decimal.InvalidOperation so bad
  fiat_amount input becomes ValidationError (libra-#38); flipped the
  tracking xfail.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-07-12 12:54:46 +02:00
commit cf1a0967bf
7 changed files with 276 additions and 68 deletions

View file

@ -279,24 +279,33 @@ async def on_invoice_paid(payment: Payment) -> None:
await release_payment_claim(payment.payment_hash)
return
# Get user's current balance to determine receivables and payables
# Get user's current balance to determine what this payment clears
balance = await fava.get_user_balance(user_id)
fiat_balances = balance.get("fiat_balances", {})
total_fiat_balance = fiat_balances.get(fiat_currency, Decimal(0))
# Determine receivables and payables based on balance
# Positive balance = user owes libra (receivable)
# Negative balance = libra owes user (payable)
if total_fiat_balance > 0:
# User owes libra
total_receivable = total_fiat_balance
total_payable = Decimal(0)
else:
# Libra owes user
total_receivable = Decimal(0)
total_payable = abs(total_fiat_balance)
# Settle only what this payment covers. The balance is already
# net (positive = user owes libra); a partial payment clears
# that much receivable, and any excess — or the whole payment
# when nothing is owed — becomes credit libra owes the user.
# (Previously partial payments cleared the FULL balance against
# a smaller payment, shipping unbalanced postings.)
tolerance = Decimal("0.01")
open_receivable = (
total_fiat_balance if total_fiat_balance > 0 else Decimal(0)
)
total_receivable = min(open_receivable, fiat_amount)
total_payable = Decimal(0)
credit_overflow = fiat_amount - total_receivable
if credit_overflow < tolerance:
# Absorb sub-cent rounding into the receivable leg.
credit_overflow = Decimal(0)
total_receivable = fiat_amount
logger.info(f"Settlement: {fiat_amount} {fiat_currency} (Receivable: {total_receivable}, Payable: {total_payable})")
logger.info(
f"Settlement: {fiat_amount} {fiat_currency} "
f"(clears receivable: {total_receivable}, credit: {credit_overflow})"
)
# Get account names
user_receivable = await get_or_create_user_account(
@ -305,23 +314,35 @@ async def on_invoice_paid(payment: Payment) -> None:
user_payable = await get_or_create_user_account(
user_id, AccountType.LIABILITY, "Accounts Payable"
)
user_credit = None
if credit_overflow > 0:
user_credit = await get_or_create_user_account(
user_id, AccountType.LIABILITY, "Credit"
)
lightning_account = await get_account_by_name("Assets:Bitcoin:Lightning")
if not lightning_account:
logger.error("Lightning account 'Assets:Bitcoin:Lightning' not found")
await release_payment_claim(payment.payment_hash)
return
# Query for unsettled entries to link this settlement back to them
# Net settlement can settle both expenses and receivables
# Link the source entries this settlement reconciles — but only
# when the payment clears the full open balance. On a partial
# payment we can't know which entries are covered, and linking
# them would make get_unsettled_entries_bql treat them as
# settled. Only same-currency entries qualify either way.
settled_links = []
try:
unsettled_expenses = await fava.get_unsettled_entries_bql(user_id, "expense")
settled_links.extend([e["link"] for e in unsettled_expenses if e.get("link")])
unsettled_receivables = await fava.get_unsettled_entries_bql(user_id, "receivable")
settled_links.extend([e["link"] for e in unsettled_receivables if e.get("link")])
except Exception as e:
logger.warning(f"Could not query unsettled entries for settlement links: {e}")
# Continue without links - settlement will still be recorded
if open_receivable > 0 and fiat_amount + tolerance >= open_receivable:
try:
unsettled_expenses = await fava.get_unsettled_entries_bql(user_id, "expense")
unsettled_receivables = await fava.get_unsettled_entries_bql(user_id, "receivable")
settled_links.extend(
e["link"]
for e in unsettled_expenses + unsettled_receivables
if e.get("link") and e.get("fiat_currency") == fiat_currency
)
except Exception as e:
logger.warning(f"Could not query unsettled entries for settlement links: {e}")
# Continue without links - settlement will still be recorded
# Format as net settlement transaction
entry = format_net_settlement_entry(
@ -338,7 +359,9 @@ async def on_invoice_paid(payment: Payment) -> None:
entry_date=datetime.now().date(),
payment_hash=payment.payment_hash,
reference=payment.payment_hash,
settled_entry_links=settled_links if settled_links else None
settled_entry_links=settled_links if settled_links else None,
credit_account=user_credit.name if user_credit else None,
credit_overflow_fiat=credit_overflow,
)
# Submit to Fava using idempotent method to prevent duplicates