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:
parent
44e10caac7
commit
cf1a0967bf
7 changed files with 276 additions and 68 deletions
71
tasks.py
71
tasks.py
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue