feat(wallet): denominate invoices in any currency the server accepts #166

Merged
padreug merged 1 commit from feat/wallet-fiat-invoices into dev 2026-09-22 21:06:39 +00:00
Owner

Receiving was sats-only. An invoice can now be quoted in any currency the LNbits instance allows, roughly 165 by default and narrowable via LNBITS_ALLOWED_CURRENCIES. That is what pricing in EUR actually needs.

Stacked on #165. The first commit here is that conversion-key fix, which the live sat preview depends on. Merge #165 first and this reduces to its own single commit.

How it works

LNbits does the conversion. unit: "EUR" with amount: 5.50 yields an ordinary BOLT11 invoice for the equivalent sats and records the original denomination on the payment's extra:

{ "fiat_currency": "EUR", "fiat_amount": 5.5, "fiat_rate": 1326.36 }

Nothing about the payment rail changes. The payer still settles over Lightning; only the quote is in fiat.

Notes on the design

  • useCurrencies goes in the base module beside the other shared payment primitives. It fetches the currency list and the instance default once per process and de-duplicates concurrent callers. Four modules (market, events, expenses, accounting) already duplicate this same getCurrencies() call, so they have somewhere to converge later.
  • Decimal places come from Intl.NumberFormat rather than a hardcoded table. Fiat is not uniformly two decimals, and "5.50 JPY" is meaningless. The step is 0.01 for EUR and 1 for JPY.
  • Switching unit clears a typed amount. Reinterpreting "10" from sats to EUR would silently produce an invoice for a wildly different value.
  • The created invoice leads with the quote (€5.50) and shows sats as secondary, noting the rate was fixed at creation, since that is the number the payer agreed to.
  • The preview is best-effort. A failed conversion renders nothing and never blocks creation, because LNbits converts authoritatively server-side.
  • Sats remains the default, and the selector is hidden entirely when the currency list is unavailable, so the wallet keeps working if the rate service is down.

Verification

Driven headlessly against the live LNbits, in a real browser:

selector lists 166 options (sats + 165 currencies)
EUR 5.50 -> preview "≈ 7,301 sats"
         -> POST {amount: 5.5, unit: "EUR"}
         -> invoice lnbc73010n... (7301 sats)
         -> displayed as "€5.50" with "Payable as 7,301 sats"
sats 250 -> POST {amount: 250, unit: "sat"}, no fiat line
JPY step/min 1; EUR step/min 0.01
switching EUR -> JPY clears the typed "12.34"

The bolt11 amount matches the preview exactly.

vue-tsc --noEmit is clean and npm run build:wallet succeeds.

Out of scope

Fiat amounts in the transaction history are deliberately not included. That applies to all payments rather than just newly created ones, and it touches the shared payment mapper that #161 rewrites. Worth a follow-up issue.

Send-side fiat is also untouched. The LNURL-pay endpoint does accept a unit, so quoting a send in fiat is possible later.

Receiving was sats-only. An invoice can now be quoted in any currency the LNbits instance allows, roughly 165 by default and narrowable via `LNBITS_ALLOWED_CURRENCIES`. That is what pricing in EUR actually needs. **Stacked on #165.** The first commit here is that conversion-key fix, which the live sat preview depends on. Merge #165 first and this reduces to its own single commit. ## How it works LNbits does the conversion. `unit: "EUR"` with `amount: 5.50` yields an ordinary BOLT11 invoice for the equivalent sats and records the original denomination on the payment's `extra`: ```json { "fiat_currency": "EUR", "fiat_amount": 5.5, "fiat_rate": 1326.36 } ``` Nothing about the payment rail changes. The payer still settles over Lightning; only the quote is in fiat. ## Notes on the design - **`useCurrencies`** goes in the base module beside the other shared payment primitives. It fetches the currency list and the instance default once per process and de-duplicates concurrent callers. Four modules (market, events, expenses, accounting) already duplicate this same `getCurrencies()` call, so they have somewhere to converge later. - **Decimal places come from `Intl.NumberFormat`** rather than a hardcoded table. Fiat is not uniformly two decimals, and "5.50 JPY" is meaningless. The step is 0.01 for EUR and 1 for JPY. - **Switching unit clears a typed amount.** Reinterpreting "10" from sats to EUR would silently produce an invoice for a wildly different value. - **The created invoice leads with the quote** (€5.50) and shows sats as secondary, noting the rate was fixed at creation, since that is the number the payer agreed to. - **The preview is best-effort.** A failed conversion renders nothing and never blocks creation, because LNbits converts authoritatively server-side. - **Sats remains the default,** and the selector is hidden entirely when the currency list is unavailable, so the wallet keeps working if the rate service is down. ## Verification Driven headlessly against the live LNbits, in a real browser: ``` selector lists 166 options (sats + 165 currencies) EUR 5.50 -> preview "≈ 7,301 sats" -> POST {amount: 5.5, unit: "EUR"} -> invoice lnbc73010n... (7301 sats) -> displayed as "€5.50" with "Payable as 7,301 sats" sats 250 -> POST {amount: 250, unit: "sat"}, no fiat line JPY step/min 1; EUR step/min 0.01 switching EUR -> JPY clears the typed "12.34" ``` The bolt11 amount matches the preview exactly. `vue-tsc --noEmit` is clean and `npm run build:wallet` succeeds. ## Out of scope Fiat amounts in the **transaction history** are deliberately not included. That applies to all payments rather than just newly created ones, and it touches the shared payment mapper that #161 rewrites. Worth a follow-up issue. Send-side fiat is also untouched. The LNURL-pay endpoint does accept a `unit`, so quoting a send in fiat is possible later.
Every fiat-to-sat conversion returned null. `convert()` looked up the
response by the `to` key it had passed in, but LNbits does not echo that
key: it always names the satoshi amount "sats" (plural), whatever the
caller asked for. So `data["sat"]` missed and the function fell through
to its null path.

    POST {from_: "EUR", to: "sat", amount: 5.50}
      -> {"EUR": 5.5, "sats": 7298, "BTC": 7.298e-05}

This silently broke the one shipped caller that converts in that
direction: `PurchaseTicketDialog` computes `lightningSats` via
`convert(amount, currency, 'sat')`, so it was always null.
`PriceConversionPreview` has a `to === 'sat'` formatting branch that
could never be reached either.

Key selection moves into `pickConverted`, which maps sat/sats onto the
plural key the server actually uses and keeps the previous fallbacks.
The reverse direction (sat to fiat) already worked and is unchanged.

Verified against the live LNbits:

  EUR -> sat (5.50):  old=null   new=7299     FIXED
  USD -> sat (10):    old=null   new=11588    FIXED
  JPY -> sat (1000):  old=null   new=7360     FIXED
  sat -> EUR (7295):  old=5.4969 new=5.4969   unchanged
Receiving was sats-only. An invoice can now be quoted in any currency
the LNbits instance allows (~165 by default, narrowable via
`LNBITS_ALLOWED_CURRENCIES`), which is what a merchant pricing in EUR
actually needs.

LNbits does the conversion: `unit: "EUR"` with `amount: 5.50` yields an
ordinary BOLT11 invoice for the equivalent sats and records the original
denomination on the payment's `extra` (`fiat_currency`, `fiat_amount`,
`fiat_rate`). Nothing about the payment rail changes — the payer still
settles over Lightning.

- `useCurrencies` (base module) fetches the currency list and the
  instance default once per process, de-duplicating concurrent callers.
  It sits beside the other shared payment primitives because four
  modules already duplicate this same `getCurrencies()` call; they can
  adopt it later.
- Decimal places come from `Intl.NumberFormat`, not a hardcoded table,
  so the input step is 0.01 for EUR and 1 for JPY, which has no minor
  unit. Amount validation tracks the unit too.
- Switching unit clears a typed amount. Reinterpreting "10" from sats to
  EUR would silently create an invoice for a wildly different value.
- The created invoice leads with what the payer was quoted (€5.50) and
  shows the sat amount as secondary, noting the rate was fixed at
  creation.
- A live "≈ N sats" preview uses the shared conversion composable. It is
  best-effort: a failed lookup renders nothing and never blocks
  creation, since LNbits converts authoritatively server-side.
- Sats stays the default, and the selector is hidden entirely if the
  currency list is unavailable, so the wallet still works if the rate
  service is down.

Verified in a headless browser against the live LNbits:

  selector lists 166 options (sats + 165 currencies)
  EUR 5.50 -> preview "≈ 7,301 sats" -> POST {amount: 5.5, unit: "EUR"}
           -> invoice lnbc73010n... (7301 sats), shown as "€5.50"
              with "Payable as 7,301 sats"
  sats 250 -> POST {amount: 250, unit: "sat"}, no fiat line
  JPY step/min 1; EUR step/min 0.01
  switching EUR -> JPY clears the typed "12.34"

Fiat in the transaction history is deliberately left out; it applies to
all payments rather than just newly created ones, and is tracked
separately.
padreug deleted branch feat/wallet-fiat-invoices 2026-09-22 21:06:40 +00:00
Sign in to join this conversation.
No description provided.