From cf1a0967bff39975aae699bf354e51f283288a33 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 12 Jul 2026 12:54:46 +0200 Subject: [PATCH] fix(settlement): per-currency netting, balance guards, Decimal rates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- beancount_format.py | 87 +++++++++++++++++++++++---- core/validation.py | 9 ++- fava_client.py | 15 +++-- tasks.py | 71 ++++++++++++++-------- tests/test_settlement_api.py | 11 ++-- tests/test_unit.py | 113 +++++++++++++++++++++++++++++++++-- views_api.py | 38 +++++++----- 7 files changed, 276 insertions(+), 68 deletions(-) diff --git a/beancount_format.py b/beancount_format.py index f233ee5..4cbc5c7 100644 --- a/beancount_format.py +++ b/beancount_format.py @@ -252,9 +252,10 @@ def format_posting_at_average_cost( 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}'") else: - # No cost - amount_str = f"{amount_sats} SATS {{}}" - logger.warning(f"format_posting_at_average_cost: cost_currency is None, using empty cost basis") + # No cost basis — omit the braces entirely. Empty "{}" is not + # valid Beancount syntax and fails to parse on ledger load. + 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 {} @@ -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": "", "btc_rate": ""} + """ + 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( user_id: str, expense_account: str, @@ -718,15 +738,18 @@ def format_net_settlement_entry( entry_date: date, payment_hash: 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]: """ 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 2. Clear receivables in EUR 3. Clear payables in EUR + 4. Credit overflow when the payment exceeds what it clears Example: Assets:Bitcoin:Lightning 565251 SATS @@ 517.00 EUR @@ -734,25 +757,61 @@ def format_net_settlement_entry( Liabilities:Payable:User 38.00 EUR = 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: user_id: User ID payment_account: Payment account (e.g., "Assets:Bitcoin:Lightning") receivable_account: User's receivable account payable_account: User's payable account amount_sats: SATS amount paid - net_fiat_amount: Net fiat amount (receivable - payable) - total_receivable_fiat: Total receivables to clear - total_payable_fiat: Total payables to clear + net_fiat_amount: Fiat value of the payment being recorded + total_receivable_fiat: Receivables cleared by this payment + total_payable_fiat: Payables cleared by this payment fiat_currency: Currency (EUR, USD) description: Payment description entry_date: Date of payment payment_hash: Lightning payment hash reference: Optional reference 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: 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 # 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. @@ -761,20 +820,26 @@ def format_net_settlement_entry( postings = [ { "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 {} }, { "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))} }, { "account": payable_account, - "amount": f"{abs(total_payable_fiat):.2f} {fiat_currency}", + "amount": f"{total_payable_fiat:.2f} {fiat_currency}", "meta": {} } ] + if credit_overflow_fiat > 0: + postings.append({ + "account": credit_account, + "amount": f"-{credit_overflow_fiat:.2f} {fiat_currency}", + "meta": {} + }) entry_meta = { "user-id": user_id, diff --git a/core/validation.py b/core/validation.py index c29f069..913bb8a 100644 --- a/core/validation.py +++ b/core/validation.py @@ -5,7 +5,7 @@ Comprehensive validation following Beancount's plugin system approach, 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 @@ -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: try: Decimal(str(metadata["fiat_amount"])) - except (ValueError, TypeError) as e: + except (ValueError, TypeError, InvalidOperation) as e: raise ValidationError( f"Invalid fiat_amount: {metadata['fiat_amount']}", {"error": str(e)} diff --git a/fava_client.py b/fava_client.py index eaed06b..a7d1703 100644 --- a/fava_client.py +++ b/fava_client.py @@ -1817,7 +1817,7 @@ class FavaClient: # Query 1: Get all original expense/receivable entries for this user # These are entries with the expense-entry or receivable-entry tag 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 WHERE account ~ '{account_pattern}' AND '{entry_tag}' IN tags @@ -1851,7 +1851,10 @@ class FavaClient: entries_by_link: Dict[str, Dict[str, Any]] = {} 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 if not links or not isinstance(links, list): @@ -1875,9 +1878,11 @@ class FavaClient: if entry_link in entries_by_link: continue - # Parse amounts - fiat_amount = abs(float(number)) if number else 0.0 - fiat_currency = "EUR" # Default, could be extracted from posting + # Parse amounts. The posting's real currency matters: callers + # net these totals per currency, and the old hardcoded "EUR" + # 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 sats_amount = 0 diff --git a/tasks.py b/tasks.py index 158d913..23dd0b5 100644 --- a/tasks.py +++ b/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 diff --git a/tests/test_settlement_api.py b/tests/test_settlement_api.py index 442a01e..f7de090 100644 --- a/tests/test_settlement_api.py +++ b/tests/test_settlement_api.py @@ -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`. """ import importlib +from decimal import Decimal from uuid import uuid4 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}" payload = r.json().get("detail") assert isinstance(payload, dict), f"expected structured detail, got {payload!r}" - assert payload.get("cash_paid") == 30.0 - assert payload.get("net_obligation") == 100.0 - assert payload.get("receivable_total") == 100.0 - assert payload.get("payable_total") == 0.0 + # Amounts are exact Decimal strings (not floats) so the operator can + # act on them without precision loss. + assert Decimal(payload.get("cash_paid")) == Decimal("30.00") + 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 diff --git a/tests/test_unit.py b/tests/test_unit.py index 1c7dabc..11779e9 100644 --- a/tests/test_unit.py +++ b/tests/test_unit.py @@ -553,11 +553,6 @@ def test_validate_metadata_fiat_amount_without_currency_raises(): 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(): with pytest.raises(val.ValidationError) as exc: 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(): 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", + } diff --git a/views_api.py b/views_api.py index 4c36427..2188d7a 100644 --- a/views_api.py +++ b/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(), }, )