From 5ab944bd42327683aa0c5b82a4476d8e486ff4d9 Mon Sep 17 00:00:00 2001 From: Padreug Date: Tue, 22 Sep 2026 22:50:17 +0200 Subject: [PATCH] feat(wallet): denominate invoices in any currency the server accepts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/lib/api/lnbits.ts | 21 +++ src/modules/base/composables/useCurrencies.ts | 110 ++++++++++++ .../wallet/components/ReceiveDialog.vue | 158 +++++++++++++++--- src/modules/wallet/services/WalletService.ts | 25 ++- 4 files changed, 290 insertions(+), 24 deletions(-) create mode 100644 src/modules/base/composables/useCurrencies.ts diff --git a/src/lib/api/lnbits.ts b/src/lib/api/lnbits.ts index 700df9a..9464b9c 100644 --- a/src/lib/api/lnbits.ts +++ b/src/lib/api/lnbits.ts @@ -226,6 +226,27 @@ export class LnbitsAPI extends BaseService { }) } + /** + * Currency codes this LNbits instance accepts for fiat-denominated + * amounts (ISO 4217). Unrestricted instances return all ~165; an admin + * can narrow it via `LNBITS_ALLOWED_CURRENCIES`. + */ + async getCurrencies(): Promise { + return this.request('/currencies', { method: 'GET' }) + } + + /** + * The instance's default accounting currency, or `null` when the admin + * has not configured one. + */ + async getDefaultCurrency(): Promise { + const data = await this.request<{ default_currency: string | null }>( + '/default-currency', + { method: 'GET' } + ) + return data.default_currency ?? null + } + isAuthenticated(): boolean { return !!this.accessToken } diff --git a/src/modules/base/composables/useCurrencies.ts b/src/modules/base/composables/useCurrencies.ts new file mode 100644 index 0000000..8f4effe --- /dev/null +++ b/src/modules/base/composables/useCurrencies.ts @@ -0,0 +1,110 @@ +import { ref, computed, type Ref } from 'vue' +import { injectService, SERVICE_TOKENS } from '@/core/di-container' +import type { LnbitsAPI } from '@/lib/api/lnbits' + +/** + * Currency codes accepted by this LNbits instance, plus the instance default. + * + * Both are server configuration that cannot change within a session, so the + * fetch is shared process-wide and de-duplicated: a dozen components calling + * this produce one request, not a dozen. Concurrent callers await the same + * in-flight promise rather than racing. + * + * Failure is non-fatal. `currencies` stays empty and callers fall back to + * sats-only, which keeps the wallet usable when the rate service is down. + */ + +const currencies = ref([]) +const defaultCurrency = ref(null) +const loading = ref(false) +const error = ref(null) + +// Shared across every caller so the request happens exactly once. +let inFlight: Promise | null = null +let loaded = false + +/** + * How many decimal places a currency is conventionally written with. + * + * Fiat is not uniformly two decimals: JPY and KRW have none, and entering + * "5.50 JPY" is meaningless. `Intl.NumberFormat` already knows each + * currency's convention, so ask it rather than hardcoding a table. + */ +export function currencyDecimals(code: string): number { + try { + const fmt = new Intl.NumberFormat(undefined, { + style: 'currency', + currency: code, + }) + return fmt.resolvedOptions().maximumFractionDigits ?? 2 + } catch { + // Unknown or non-ISO code — assume the common case. + return 2 + } +} + +/** Format an amount with its currency symbol, in the viewer's locale. */ +export function formatCurrency(amount: number, code: string): string { + try { + return new Intl.NumberFormat(undefined, { + style: 'currency', + currency: code, + }).format(amount) + } catch { + return `${amount.toFixed(currencyDecimals(code))} ${code}` + } +} + +export function useCurrencies(): { + currencies: Ref + defaultCurrency: Ref + loading: Ref + error: Ref + hasCurrencies: Ref + load: () => Promise + currencyDecimals: typeof currencyDecimals + formatCurrency: typeof formatCurrency +} { + const lnbitsAPI = injectService(SERVICE_TOKENS.LNBITS_API) + + async function load(): Promise { + if (loaded) return + if (inFlight) return inFlight + + loading.value = true + error.value = null + + inFlight = (async () => { + try { + // The default currency is optional context, so a failure there must + // not cost us the currency list itself. + const [list, fallback] = await Promise.all([ + lnbitsAPI.getCurrencies(), + lnbitsAPI.getDefaultCurrency().catch(() => null), + ]) + currencies.value = Array.isArray(list) ? list : [] + defaultCurrency.value = fallback + loaded = true + } catch (err) { + error.value = err instanceof Error ? err.message : 'Failed to load currencies' + console.warn('[useCurrencies] load failed:', err) + } finally { + loading.value = false + inFlight = null + } + })() + + return inFlight + } + + return { + currencies, + defaultCurrency, + loading, + error, + hasCurrencies: computed(() => currencies.value.length > 0), + load, + currencyDecimals, + formatCurrency, + } +} diff --git a/src/modules/wallet/components/ReceiveDialog.vue b/src/modules/wallet/components/ReceiveDialog.vue index a35f9af..ccf60a5 100644 --- a/src/modules/wallet/components/ReceiveDialog.vue +++ b/src/modules/wallet/components/ReceiveDialog.vue @@ -1,5 +1,5 @@