Settlement balance guards, per-currency netting, Decimal rates #57

Open
padreug wants to merge 1 commit from fix/settlement-balance-and-decimal into fix/payment-idempotency
7 changed files with 276 additions and 68 deletions
Showing only changes of commit cf1a0967bf - Show all commits

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>
Padreug 2026-07-12 12:54:46 +02:00

View file

@ -252,9 +252,10 @@ def format_posting_at_average_cost(
amount_str = f"{amount_sats} SATS {{{cost_currency}}}" amount_str = f"{amount_sats} SATS {{{cost_currency}}}"
logger.info(f"format_posting_at_average_cost: Generated amount_str='{amount_str}' with cost_currency='{cost_currency}'") logger.info(f"format_posting_at_average_cost: Generated amount_str='{amount_str}' with cost_currency='{cost_currency}'")
else: else:
# No cost # No cost basis — omit the braces entirely. Empty "{}" is not
amount_str = f"{amount_sats} SATS {{}}" # valid Beancount syntax and fails to parse on ledger load.
logger.warning(f"format_posting_at_average_cost: cost_currency is None, using empty cost basis") amount_str = f"{amount_sats} SATS"
logger.warning(f"format_posting_at_average_cost: cost_currency is None, omitting cost basis")
posting_meta = metadata or {} posting_meta = metadata or {}
@ -304,6 +305,25 @@ def format_posting_simple(
} }
def fiat_rate_metadata(amount_sats: int, fiat_amount: Decimal) -> Dict[str, str]:
"""Exchange-rate metadata (sats per fiat unit, fiat per BTC) as exact
Decimal strings.
These values become the cost-basis record for the entry, so they must
not carry float drift CLAUDE.md mandates Decimal for all fiat math.
Returns:
{"fiat_rate": "<sats per fiat unit>", "btc_rate": "<fiat per BTC>"}
"""
if amount_sats <= 0 or fiat_amount <= 0:
return {"fiat_rate": "0", "btc_rate": "0"}
fiat_rate = (Decimal(amount_sats) / fiat_amount).quantize(Decimal("0.000001"))
btc_rate = (
fiat_amount / Decimal(amount_sats) * Decimal(100_000_000)
).quantize(Decimal("0.01"))
return {"fiat_rate": str(fiat_rate), "btc_rate": str(btc_rate)}
def format_expense_entry( def format_expense_entry(
user_id: str, user_id: str,
expense_account: str, expense_account: str,
@ -718,15 +738,18 @@ def format_net_settlement_entry(
entry_date: date, entry_date: date,
payment_hash: Optional[str] = None, payment_hash: Optional[str] = None,
reference: Optional[str] = None, reference: Optional[str] = None,
settled_entry_links: Optional[List[str]] = None settled_entry_links: Optional[List[str]] = None,
credit_account: Optional[str] = None,
credit_overflow_fiat: Decimal = Decimal(0),
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Format a net settlement payment entry (user paying net balance). Format a net settlement payment entry (user paying net balance).
Creates a three-posting transaction: Creates a three- to four-posting transaction:
1. Lightning payment in SATS with @@ total price notation 1. Lightning payment in SATS with @@ total price notation
2. Clear receivables in EUR 2. Clear receivables in EUR
3. Clear payables in EUR 3. Clear payables in EUR
4. Credit overflow when the payment exceeds what it clears
Example: Example:
Assets:Bitcoin:Lightning 565251 SATS @@ 517.00 EUR Assets:Bitcoin:Lightning 565251 SATS @@ 517.00 EUR
@ -734,25 +757,61 @@ def format_net_settlement_entry(
Liabilities:Payable:User 38.00 EUR Liabilities:Payable:User 38.00 EUR
= 517 - 555 + 38 = 0 = 517 - 555 + 38 = 0
Constraint enforced inline (same contract as
`format_fiat_net_settlement_entry`):
net_fiat_amount = total_receivable_fiat - total_payable_fiat
+ credit_overflow_fiat
Args: Args:
user_id: User ID user_id: User ID
payment_account: Payment account (e.g., "Assets:Bitcoin:Lightning") payment_account: Payment account (e.g., "Assets:Bitcoin:Lightning")
receivable_account: User's receivable account receivable_account: User's receivable account
payable_account: User's payable account payable_account: User's payable account
amount_sats: SATS amount paid amount_sats: SATS amount paid
net_fiat_amount: Net fiat amount (receivable - payable) net_fiat_amount: Fiat value of the payment being recorded
total_receivable_fiat: Total receivables to clear total_receivable_fiat: Receivables cleared by this payment
total_payable_fiat: Total payables to clear total_payable_fiat: Payables cleared by this payment
fiat_currency: Currency (EUR, USD) fiat_currency: Currency (EUR, USD)
description: Payment description description: Payment description
entry_date: Date of payment entry_date: Date of payment
payment_hash: Lightning payment hash payment_hash: Lightning payment hash
reference: Optional reference reference: Optional reference
settled_entry_links: List of expense/receivable links being settled (e.g., ["exp-abc123", "rcv-def456"]) settled_entry_links: List of expense/receivable links being settled (e.g., ["exp-abc123", "rcv-def456"])
credit_account: User's credit account receiving overflow (required
when credit_overflow_fiat > 0)
credit_overflow_fiat: Payment excess beyond what it clears, absorbed
as a liability libra owes the user going forward
Returns: Returns:
Fava API entry dict Fava API entry dict
Raises:
ValueError: if any amount is negative, or the payment doesn't
balance against what it clears an unbalanced settlement
must never reach the ledger.
""" """
for label, value in (
("net_fiat_amount", net_fiat_amount),
("total_receivable_fiat", total_receivable_fiat),
("total_payable_fiat", total_payable_fiat),
("credit_overflow_fiat", credit_overflow_fiat),
):
if value < 0:
raise ValueError(f"{label} must be non-negative; got {value}")
expected_payment = (
total_receivable_fiat - total_payable_fiat + credit_overflow_fiat
)
if abs(net_fiat_amount - expected_payment) > Decimal("0.01"):
raise ValueError(
f"net_fiat_amount {net_fiat_amount} does not match expected "
f"{expected_payment} (= receivable {total_receivable_fiat} "
f"- payable {total_payable_fiat} + credit {credit_overflow_fiat}); "
f"refusing to write an unbalanced settlement"
)
if credit_overflow_fiat > 0 and not credit_account:
raise ValueError("credit_account required when credit_overflow_fiat > 0")
# Build postings for net settlement # Build postings for net settlement
# Note: We use @@ (total price) syntax for cleaner formatting, but Fava's API # Note: We use @@ (total price) syntax for cleaner formatting, but Fava's API
# will convert this to @ (per-unit price) with a long decimal when writing to file. # will convert this to @ (per-unit price) with a long decimal when writing to file.
@ -761,20 +820,26 @@ def format_net_settlement_entry(
postings = [ postings = [
{ {
"account": payment_account, "account": payment_account,
"amount": f"{abs(amount_sats)} SATS @@ {abs(net_fiat_amount):.2f} {fiat_currency}", "amount": f"{abs(amount_sats)} SATS @@ {net_fiat_amount:.2f} {fiat_currency}",
"meta": {"payment-hash": payment_hash} if payment_hash else {} "meta": {"payment-hash": payment_hash} if payment_hash else {}
}, },
{ {
"account": receivable_account, "account": receivable_account,
"amount": f"-{abs(total_receivable_fiat):.2f} {fiat_currency}", "amount": f"-{total_receivable_fiat:.2f} {fiat_currency}",
"meta": {"sats-equivalent": str(abs(amount_sats))} "meta": {"sats-equivalent": str(abs(amount_sats))}
}, },
{ {
"account": payable_account, "account": payable_account,
"amount": f"{abs(total_payable_fiat):.2f} {fiat_currency}", "amount": f"{total_payable_fiat:.2f} {fiat_currency}",
"meta": {} "meta": {}
} }
] ]
if credit_overflow_fiat > 0:
postings.append({
"account": credit_account,
"amount": f"-{credit_overflow_fiat:.2f} {fiat_currency}",
"meta": {}
})
entry_meta = { entry_meta = {
"user-id": user_id, "user-id": user_id,

View file

@ -5,7 +5,7 @@ Comprehensive validation following Beancount's plugin system approach,
but implemented as simple functions that can be called directly. but implemented as simple functions that can be called directly.
""" """
from decimal import Decimal from decimal import Decimal, InvalidOperation
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
@ -278,11 +278,14 @@ def validate_metadata(
} }
) )
# Validate fiat amount is valid Decimal # Validate fiat amount is valid Decimal. InvalidOperation is what
# Decimal actually raises on garbage input ("abc") — it is not a
# ValueError subclass, so without it the raw exception leaked to
# callers (libra-#38).
if has_fiat_amount: if has_fiat_amount:
try: try:
Decimal(str(metadata["fiat_amount"])) Decimal(str(metadata["fiat_amount"]))
except (ValueError, TypeError) as e: except (ValueError, TypeError, InvalidOperation) as e:
raise ValidationError( raise ValidationError(
f"Invalid fiat_amount: {metadata['fiat_amount']}", f"Invalid fiat_amount: {metadata['fiat_amount']}",
{"error": str(e)} {"error": str(e)}

View file

@ -1817,7 +1817,7 @@ class FavaClient:
# Query 1: Get all original expense/receivable entries for this user # Query 1: Get all original expense/receivable entries for this user
# These are entries with the expense-entry or receivable-entry tag # These are entries with the expense-entry or receivable-entry tag
original_query = f""" original_query = f"""
SELECT date, narration, account, number, weight, links, SELECT date, narration, account, number, currency, weight, links,
any_meta('entry-id') as entry_id any_meta('entry-id') as entry_id
WHERE account ~ '{account_pattern}' WHERE account ~ '{account_pattern}'
AND '{entry_tag}' IN tags AND '{entry_tag}' IN tags
@ -1851,7 +1851,10 @@ class FavaClient:
entries_by_link: Dict[str, Dict[str, Any]] = {} entries_by_link: Dict[str, Dict[str, Any]] = {}
for row in original_result["rows"]: for row in original_result["rows"]:
date_val, narration, account, number, weight, links, entry_id = row (
date_val, narration, account, number, currency,
weight, links, entry_id,
) = row
# Skip if no links # Skip if no links
if not links or not isinstance(links, list): if not links or not isinstance(links, list):
@ -1875,9 +1878,11 @@ class FavaClient:
if entry_link in entries_by_link: if entry_link in entries_by_link:
continue continue
# Parse amounts # Parse amounts. The posting's real currency matters: callers
fiat_amount = abs(float(number)) if number else 0.0 # net these totals per currency, and the old hardcoded "EUR"
fiat_currency = "EUR" # Default, could be extracted from posting # let USD (or SATS-only) entries be summed as if they were EUR.
fiat_amount = str(abs(Decimal(str(number)))) if number else "0"
fiat_currency = currency or "EUR"
# Parse SATS from weight column # Parse SATS from weight column
sats_amount = 0 sats_amount = 0

View file

@ -279,24 +279,33 @@ async def on_invoice_paid(payment: Payment) -> None:
await release_payment_claim(payment.payment_hash) await release_payment_claim(payment.payment_hash)
return 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) balance = await fava.get_user_balance(user_id)
fiat_balances = balance.get("fiat_balances", {}) fiat_balances = balance.get("fiat_balances", {})
total_fiat_balance = fiat_balances.get(fiat_currency, Decimal(0)) total_fiat_balance = fiat_balances.get(fiat_currency, Decimal(0))
# Determine receivables and payables based on balance # Settle only what this payment covers. The balance is already
# Positive balance = user owes libra (receivable) # net (positive = user owes libra); a partial payment clears
# Negative balance = libra owes user (payable) # that much receivable, and any excess — or the whole payment
if total_fiat_balance > 0: # when nothing is owed — becomes credit libra owes the user.
# User owes libra # (Previously partial payments cleared the FULL balance against
total_receivable = total_fiat_balance # 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) total_payable = Decimal(0)
else: credit_overflow = fiat_amount - total_receivable
# Libra owes user if credit_overflow < tolerance:
total_receivable = Decimal(0) # Absorb sub-cent rounding into the receivable leg.
total_payable = abs(total_fiat_balance) 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 # Get account names
user_receivable = await get_or_create_user_account( user_receivable = await get_or_create_user_account(
@ -305,20 +314,32 @@ async def on_invoice_paid(payment: Payment) -> None:
user_payable = await get_or_create_user_account( user_payable = await get_or_create_user_account(
user_id, AccountType.LIABILITY, "Accounts Payable" 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") lightning_account = await get_account_by_name("Assets:Bitcoin:Lightning")
if not lightning_account: if not lightning_account:
logger.error("Lightning account 'Assets:Bitcoin:Lightning' not found") logger.error("Lightning account 'Assets:Bitcoin:Lightning' not found")
await release_payment_claim(payment.payment_hash) await release_payment_claim(payment.payment_hash)
return return
# Query for unsettled entries to link this settlement back to them # Link the source entries this settlement reconciles — but only
# Net settlement can settle both expenses and receivables # 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 = [] settled_links = []
if open_receivable > 0 and fiat_amount + tolerance >= open_receivable:
try: try:
unsettled_expenses = await fava.get_unsettled_entries_bql(user_id, "expense") 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") 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")]) 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: except Exception as e:
logger.warning(f"Could not query unsettled entries for settlement links: {e}") logger.warning(f"Could not query unsettled entries for settlement links: {e}")
# Continue without links - settlement will still be recorded # Continue without links - settlement will still be recorded
@ -338,7 +359,9 @@ async def on_invoice_paid(payment: Payment) -> None:
entry_date=datetime.now().date(), entry_date=datetime.now().date(),
payment_hash=payment.payment_hash, payment_hash=payment.payment_hash,
reference=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 # Submit to Fava using idempotent method to prevent duplicates

View file

@ -10,6 +10,7 @@ Underpay without explicit entry-picks returns 400 with diff details so
the operator can either pay the exact net or specify `settled_entry_links`. the operator can either pay the exact net or specify `settled_entry_links`.
""" """
import importlib import importlib
from decimal import Decimal
from uuid import uuid4 from uuid import uuid4
import pytest import pytest
@ -268,10 +269,12 @@ async def test_underpay_without_explicit_links_returns_400(
assert r.status_code == 400, f"expected 400, got {r.status_code}: {r.text}" assert r.status_code == 400, f"expected 400, got {r.status_code}: {r.text}"
payload = r.json().get("detail") payload = r.json().get("detail")
assert isinstance(payload, dict), f"expected structured detail, got {payload!r}" assert isinstance(payload, dict), f"expected structured detail, got {payload!r}"
assert payload.get("cash_paid") == 30.0 # Amounts are exact Decimal strings (not floats) so the operator can
assert payload.get("net_obligation") == 100.0 # act on them without precision loss.
assert payload.get("receivable_total") == 100.0 assert Decimal(payload.get("cash_paid")) == Decimal("30.00")
assert payload.get("payable_total") == 0.0 assert Decimal(payload.get("net_obligation")) == Decimal("100.00")
assert Decimal(payload.get("receivable_total")) == Decimal("100.00")
assert Decimal(payload.get("payable_total")) == Decimal("0")
@pytest.mark.anyio @pytest.mark.anyio

View file

@ -553,11 +553,6 @@ def test_validate_metadata_fiat_amount_without_currency_raises():
val.validate_metadata({"fiat_amount": "10.00"}) val.validate_metadata({"fiat_amount": "10.00"})
@pytest.mark.xfail(
reason="libra/issues/38 — except clause doesn't catch decimal.InvalidOperation, "
"so the raw exception leaks instead of becoming ValidationError. Flip when fixed.",
strict=True,
)
def test_validate_metadata_fiat_amount_invalid_decimal_raises(): def test_validate_metadata_fiat_amount_invalid_decimal_raises():
with pytest.raises(val.ValidationError) as exc: with pytest.raises(val.ValidationError) as exc:
val.validate_metadata({"fiat_amount": "not-a-number", "fiat_currency": "EUR"}) val.validate_metadata({"fiat_amount": "not-a-number", "fiat_currency": "EUR"})
@ -570,3 +565,111 @@ def test_validate_metadata_both_present_passes():
def test_validate_metadata_neither_present_passes(): def test_validate_metadata_neither_present_passes():
val.validate_metadata({"source": "api"}) val.validate_metadata({"source": "api"})
# ---------------------------------------------------------------------------
# format_net_settlement_entry — balance guard + credit overflow
# ---------------------------------------------------------------------------
def _net_settlement(**overrides):
kwargs = dict(
user_id="abc12345",
payment_account="Assets:Bitcoin:Lightning",
receivable_account="Assets:Receivable:User-abc12345",
payable_account="Liabilities:Payable:User-abc12345",
amount_sats=565251,
net_fiat_amount=Decimal("517.00"),
total_receivable_fiat=Decimal("555.00"),
total_payable_fiat=Decimal("38.00"),
fiat_currency="EUR",
description="test settlement",
entry_date=date(2026, 7, 12),
payment_hash="ff" * 32,
)
kwargs.update(overrides)
return bf.format_net_settlement_entry(**kwargs)
def test_net_settlement_balanced_passes():
entry = _net_settlement()
amounts = [p["amount"] for p in entry["postings"]]
assert any("@@ 517.00 EUR" in a for a in amounts)
assert "-555.00 EUR" in amounts
assert "38.00 EUR" in amounts
def test_net_settlement_unbalanced_partial_payment_raises():
# Payment of 300 can't clear a 555 receivable net of 38 payable —
# this is the pre-fix partial-payment shape that shipped unbalanced
# postings to the ledger.
with pytest.raises(ValueError, match="unbalanced"):
_net_settlement(net_fiat_amount=Decimal("300.00"))
def test_net_settlement_negative_amount_raises():
with pytest.raises(ValueError, match="non-negative"):
_net_settlement(total_receivable_fiat=Decimal("-1.00"))
def test_net_settlement_credit_overflow_adds_leg():
entry = _net_settlement(
net_fiat_amount=Decimal("600.00"),
credit_account="Liabilities:Credit:User-abc12345",
credit_overflow_fiat=Decimal("83.00"),
)
amounts = [p["amount"] for p in entry["postings"]]
assert "-83.00 EUR" in amounts
def test_net_settlement_credit_overflow_without_account_raises():
with pytest.raises(ValueError, match="credit_account"):
_net_settlement(
net_fiat_amount=Decimal("600.00"),
credit_overflow_fiat=Decimal("83.00"),
)
# ---------------------------------------------------------------------------
# format_posting_at_average_cost — no empty cost braces
# ---------------------------------------------------------------------------
def test_average_cost_posting_without_currency_omits_braces():
posting = bf.format_posting_at_average_cost(
account="Assets:Receivable:User-abc", amount_sats=-996896,
)
assert posting["amount"] == "-996896 SATS"
assert "{" not in posting["amount"]
def test_average_cost_posting_with_currency_keeps_braces():
posting = bf.format_posting_at_average_cost(
account="Assets:Receivable:User-abc",
amount_sats=-996896,
cost_currency="EUR",
)
assert posting["amount"] == "-996896 SATS {EUR}"
# ---------------------------------------------------------------------------
# fiat_rate_metadata — Decimal-exact rate strings
# ---------------------------------------------------------------------------
def test_fiat_rate_metadata_is_exact():
meta = bf.fiat_rate_metadata(107419, Decimal("100.00"))
assert meta["fiat_rate"] == "1074.190000"
assert meta["btc_rate"] == "93093.40"
# No float artifacts like 1074.1899999999998
Decimal(meta["fiat_rate"])
Decimal(meta["btc_rate"])
def test_fiat_rate_metadata_zero_amounts():
assert bf.fiat_rate_metadata(0, Decimal("100.00")) == {
"fiat_rate": "0", "btc_rate": "0",
}
assert bf.fiat_rate_metadata(1000, Decimal("0")) == {
"fiat_rate": "0", "btc_rate": "0",
}

View file

@ -13,6 +13,7 @@ from lnbits.decorators import (
) )
from lnbits.utils.exchange_rates import allowed_currencies, fiat_amount_as_satoshis from lnbits.utils.exchange_rates import allowed_currencies, fiat_amount_as_satoshis
from .beancount_format import fiat_rate_metadata
from .crud import ( from .crud import (
approve_manual_payment_request, approve_manual_payment_request,
check_balance_assertion, check_balance_assertion,
@ -1074,8 +1075,7 @@ async def api_create_expense_entry(
metadata = { metadata = {
"fiat_currency": data.currency.upper(), "fiat_currency": data.currency.upper(),
"fiat_amount": str(data.amount.quantize(Decimal("0.001"))), # Store as string with 3 decimal places "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, **fiat_rate_metadata(amount_sats, data.amount),
"btc_rate": float(data.amount) / float(amount_sats) * 100_000_000 if amount_sats > 0 else 0,
} }
# Get or create expense account # Get or create expense account
@ -1272,8 +1272,7 @@ async def api_create_income_entry(
metadata = { metadata = {
"fiat_currency": fiat_currency, "fiat_currency": fiat_currency,
"fiat_amount": str(data.amount.quantize(Decimal("0.001"))), "fiat_amount": str(data.amount.quantize(Decimal("0.001"))),
"fiat_rate": float(amount_sats) / float(data.amount) if data.amount > 0 else 0, **fiat_rate_metadata(amount_sats, data.amount),
"btc_rate": float(data.amount) / float(amount_sats) * 100_000_000 if amount_sats > 0 else 0,
} }
# Submit to Fava # Submit to Fava
@ -1371,8 +1370,7 @@ async def api_create_receivable_entry(
metadata = { metadata = {
"fiat_currency": data.currency.upper(), "fiat_currency": data.currency.upper(),
"fiat_amount": str(data.amount.quantize(Decimal("0.001"))), # Store as string with 3 decimal places "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, **fiat_rate_metadata(amount_sats, data.amount),
"btc_rate": float(data.amount) / float(amount_sats) * 100_000_000 if amount_sats > 0 else 0,
} }
# Get or create revenue account # Get or create revenue account
@ -1760,15 +1758,10 @@ async def api_generate_payment_invoice(
proportion = Decimal(data.amount) / Decimal(total_sat_balance) proportion = Decimal(data.amount) / Decimal(total_sat_balance)
invoice_fiat_amount = abs(total_fiat_balance) * proportion 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({ invoice_extra.update({
"fiat_currency": fiat_currency, "fiat_currency": fiat_currency,
"fiat_amount": str(invoice_fiat_amount.quantize(Decimal("0.001"))), "fiat_amount": str(invoice_fiat_amount.quantize(Decimal("0.001"))),
"fiat_rate": fiat_rate, **fiat_rate_metadata(data.amount, invoice_fiat_amount),
"btc_rate": btc_rate,
}) })
logger.info(f"Invoice extra metadata: {invoice_extra}") 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_payables = await fava.get_unsettled_entries_bql(data.user_id, "expense")
unsettled_receivables = await fava.get_unsettled_entries_bql(data.user_id, "receivable") 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( payable_total = sum(
(Decimal(str(e["fiat_amount"])) for e in unsettled_payables), (Decimal(str(e["fiat_amount"])) for e in unsettled_payables),
Decimal(0), Decimal(0),
@ -2107,10 +2113,10 @@ async def api_settle_receivable(
"net to clear all open entries, or pass " "net to clear all open entries, or pass "
"`settled_entry_links` to settle a specific subset." "`settled_entry_links` to settle a specific subset."
), ),
"cash_paid": float(cash_paid), "cash_paid": str(cash_paid),
"net_obligation": float(net_obligation), "net_obligation": str(net_obligation),
"receivable_total": float(receivable_total), "receivable_total": str(receivable_total),
"payable_total": float(payable_total), "payable_total": str(payable_total),
"currency": data.currency.upper(), "currency": data.currency.upper(),
}, },
) )