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
38
views_api.py
38
views_api.py
|
|
@ -13,6 +13,7 @@ from lnbits.decorators import (
|
|||
)
|
||||
from lnbits.utils.exchange_rates import allowed_currencies, fiat_amount_as_satoshis
|
||||
|
||||
from .beancount_format import fiat_rate_metadata
|
||||
from .crud import (
|
||||
approve_manual_payment_request,
|
||||
check_balance_assertion,
|
||||
|
|
@ -1074,8 +1075,7 @@ async def api_create_expense_entry(
|
|||
metadata = {
|
||||
"fiat_currency": data.currency.upper(),
|
||||
"fiat_amount": str(data.amount.quantize(Decimal("0.001"))), # Store as string with 3 decimal places
|
||||
"fiat_rate": float(amount_sats) / float(data.amount) if data.amount > 0 else 0,
|
||||
"btc_rate": float(data.amount) / float(amount_sats) * 100_000_000 if amount_sats > 0 else 0,
|
||||
**fiat_rate_metadata(amount_sats, data.amount),
|
||||
}
|
||||
|
||||
# Get or create expense account
|
||||
|
|
@ -1272,8 +1272,7 @@ async def api_create_income_entry(
|
|||
metadata = {
|
||||
"fiat_currency": fiat_currency,
|
||||
"fiat_amount": str(data.amount.quantize(Decimal("0.001"))),
|
||||
"fiat_rate": float(amount_sats) / float(data.amount) if data.amount > 0 else 0,
|
||||
"btc_rate": float(data.amount) / float(amount_sats) * 100_000_000 if amount_sats > 0 else 0,
|
||||
**fiat_rate_metadata(amount_sats, data.amount),
|
||||
}
|
||||
|
||||
# Submit to Fava
|
||||
|
|
@ -1371,8 +1370,7 @@ async def api_create_receivable_entry(
|
|||
metadata = {
|
||||
"fiat_currency": data.currency.upper(),
|
||||
"fiat_amount": str(data.amount.quantize(Decimal("0.001"))), # Store as string with 3 decimal places
|
||||
"fiat_rate": float(amount_sats) / float(data.amount) if data.amount > 0 else 0,
|
||||
"btc_rate": float(data.amount) / float(amount_sats) * 100_000_000 if amount_sats > 0 else 0,
|
||||
**fiat_rate_metadata(amount_sats, data.amount),
|
||||
}
|
||||
|
||||
# Get or create revenue account
|
||||
|
|
@ -1760,15 +1758,10 @@ async def api_generate_payment_invoice(
|
|||
proportion = Decimal(data.amount) / Decimal(total_sat_balance)
|
||||
invoice_fiat_amount = abs(total_fiat_balance) * proportion
|
||||
|
||||
# Calculate fiat rate (sats per fiat unit)
|
||||
fiat_rate = float(data.amount) / float(invoice_fiat_amount) if invoice_fiat_amount > 0 else 0
|
||||
btc_rate = float(invoice_fiat_amount) / float(data.amount) * 100_000_000 if data.amount > 0 else 0
|
||||
|
||||
invoice_extra.update({
|
||||
"fiat_currency": fiat_currency,
|
||||
"fiat_amount": str(invoice_fiat_amount.quantize(Decimal("0.001"))),
|
||||
"fiat_rate": fiat_rate,
|
||||
"btc_rate": btc_rate,
|
||||
**fiat_rate_metadata(data.amount, invoice_fiat_amount),
|
||||
})
|
||||
|
||||
logger.info(f"Invoice extra metadata: {invoice_extra}")
|
||||
|
|
@ -2064,6 +2057,19 @@ async def api_settle_receivable(
|
|||
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),
|
||||
|
|
@ -2107,10 +2113,10 @@ async def api_settle_receivable(
|
|||
"net to clear all open entries, or pass "
|
||||
"`settled_entry_links` to settle a specific subset."
|
||||
),
|
||||
"cash_paid": float(cash_paid),
|
||||
"net_obligation": float(net_obligation),
|
||||
"receivable_total": float(receivable_total),
|
||||
"payable_total": float(payable_total),
|
||||
"cash_paid": str(cash_paid),
|
||||
"net_obligation": str(net_obligation),
|
||||
"receivable_total": str(receivable_total),
|
||||
"payable_total": str(payable_total),
|
||||
"currency": data.currency.upper(),
|
||||
},
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue