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
4 changed files with 290 additions and 24 deletions
Showing only changes of commit 5ab944bd42 - Show all commits

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

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 2026-09-22 22:50:17 +02:00

View file

@ -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<string[]> {
return this.request<string[]>('/currencies', { method: 'GET' })
}
/**
* The instance's default accounting currency, or `null` when the admin
* has not configured one.
*/
async getDefaultCurrency(): Promise<string | null> {
const data = await this.request<{ default_currency: string | null }>(
'/default-currency',
{ method: 'GET' }
)
return data.default_currency ?? null
}
isAuthenticated(): boolean { isAuthenticated(): boolean {
return !!this.accessToken return !!this.accessToken
} }

View file

@ -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<string[]>([])
const defaultCurrency = ref<string | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
// Shared across every caller so the request happens exactly once.
let inFlight: Promise<void> | 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<string[]>
defaultCurrency: Ref<string | null>
loading: Ref<boolean>
error: Ref<string | null>
hasCurrencies: Ref<boolean>
load: () => Promise<void>
currencyDecimals: typeof currencyDecimals
formatCurrency: typeof formatCurrency
} {
const lnbitsAPI = injectService<LnbitsAPI>(SERVICE_TOKENS.LNBITS_API)
async function load(): Promise<void> {
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,
}
}

View file

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref, computed, watch, onMounted } from 'vue'
import { useForm } from 'vee-validate' import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod' import { toTypedSchema } from '@vee-validate/zod'
import * as z from 'zod' import * as z from 'zod'
@ -11,8 +11,19 @@ import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { QrCode, Copy, Check, Loader2 } from 'lucide-vue-next' import { QrCode, Copy, Check, Loader2 } from 'lucide-vue-next'
import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form' import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { useCurrencies } from '@/modules/base/composables/useCurrencies'
import { usePriceConversion } from '@/modules/base/composables/usePriceConversion'
import type { Invoice } from '../services/WalletService' import type { Invoice } from '../services/WalletService'
const SAT = 'sat'
interface Props { interface Props {
open: boolean open: boolean
} }
@ -29,11 +40,47 @@ const walletService = injectService(SERVICE_TOKENS.WALLET_SERVICE) as any
const toastService = injectService(SERVICE_TOKENS.TOAST_SERVICE) as any const toastService = injectService(SERVICE_TOKENS.TOAST_SERVICE) as any
const paymentService = injectService(SERVICE_TOKENS.PAYMENT_SERVICE) as any const paymentService = injectService(SERVICE_TOKENS.PAYMENT_SERVICE) as any
// Form validation schema // Denomination currency: "sat", or an ISO 4217 code the server accepts.
const formSchema = toTypedSchema(z.object({ const unit = ref<string>(SAT)
amount: z.number().min(1, "Amount must be at least 1 sat").max(10000000, "Amount too large"), const isFiat = computed(() => unit.value !== SAT)
memo: z.string().max(640, "Description too long").optional()
})) const {
currencies,
defaultCurrency,
hasCurrencies,
load: loadCurrencies,
currencyDecimals,
formatCurrency,
} = useCurrencies()
const { useLivePreview } = usePriceConversion()
onMounted(async () => {
await loadCurrencies()
// Honour the instance's default accounting currency when the admin set
// one and it is actually offered. Otherwise stay on sats.
const preferred = defaultCurrency.value
if (preferred && currencies.value.includes(preferred)) {
unit.value = preferred
}
})
// Sats are whole numbers; fiat follows its own convention (JPY has no
// decimals, EUR has two), so the step and the minimum both track the unit.
const amountStep = computed(() =>
isFiat.value ? Math.pow(10, -currencyDecimals(unit.value)) : 1
)
const amountMin = computed(() => amountStep.value)
// Form validation schema. The floor differs by unit: 1 sat vs 0.01 EUR.
const formSchema = computed(() =>
toTypedSchema(z.object({
amount: z
.number()
.min(amountMin.value, `Amount must be at least ${amountMin.value} ${unit.value}`)
.max(10000000, 'Amount too large'),
memo: z.string().max(640, 'Description too long').optional()
}))
)
// Form setup // Form setup
const form = useForm({ const form = useForm({
@ -44,9 +91,28 @@ const form = useForm({
} }
}) })
const { resetForm, meta } = form const { resetForm, meta, values } = form
const isFormValid = computed(() => meta.value.valid) const isFormValid = computed(() => meta.value.valid)
// Live "≈ N sats" preview under a fiat amount, so the payer's quote is not a
// surprise. Best-effort: a failed conversion renders nothing and never blocks
// invoice creation, since LNbits does the authoritative conversion server-side.
const previewAmount = computed(() => (isFiat.value ? Number(values.amount) || 0 : 0))
const previewFrom = computed(() => unit.value)
const previewTo = computed(() => SAT)
const { result: satPreview, loading: satPreviewLoading } = useLivePreview(
previewAmount,
previewFrom,
previewTo
)
// Switching unit invalidates a typed amount: "10" means something very
// different in sats than in EUR, and silently reinterpreting it risks an
// invoice for the wrong value.
watch(unit, () => {
form.setFieldValue('amount', undefined as unknown as number)
})
// State // State
const createdInvoice = ref<Invoice | null>(null) const createdInvoice = ref<Invoice | null>(null)
const copiedField = ref<string | null>(null) const copiedField = ref<string | null>(null)
@ -96,7 +162,8 @@ const onSubmit = form.handleSubmit(async (formValues) => {
try { try {
const invoice = await walletService.createInvoice({ const invoice = await walletService.createInvoice({
amount: formValues.amount, amount: formValues.amount,
memo: formValues.memo || 'Payment request' memo: formValues.memo || 'Payment request',
unit: unit.value
// Let server use default expiry // Let server use default expiry
}) })
@ -197,18 +264,49 @@ function formatExpiry(seconds: number): string {
<form @submit="onSubmit" class="space-y-3 sm:space-y-4"> <form @submit="onSubmit" class="space-y-3 sm:space-y-4">
<FormField v-slot="{ componentField }" name="amount"> <FormField v-slot="{ componentField }" name="amount">
<FormItem> <FormItem>
<FormLabel class="text-sm font-medium">Amount (sats) *</FormLabel> <FormLabel class="text-sm font-medium">Amount *</FormLabel>
<div class="flex gap-2">
<FormControl> <FormControl>
<Input <Input
type="number" type="number"
min="1" :min="amountMin"
:step="amountStep"
max="10000000" max="10000000"
placeholder="Enter amount in sats" :placeholder="isFiat ? `Enter amount in ${unit}` : 'Enter amount in sats'"
class="h-9 sm:h-10" class="h-9 sm:h-10 flex-1"
v-bind="componentField" v-bind="componentField"
/> />
</FormControl> </FormControl>
<FormDescription class="text-xs sm:text-sm">Amount to request in satoshis</FormDescription> <!-- Sats-only when the server offers no currencies (or the
list failed to load), so the wallet still works. -->
<Select v-if="hasCurrencies" v-model="unit">
<SelectTrigger class="h-9 sm:h-10 w-[7.5rem] shrink-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="sat">sats</SelectItem>
<SelectItem
v-for="currency in currencies"
:key="currency"
:value="currency"
>
{{ currency }}
</SelectItem>
</SelectContent>
</Select>
</div>
<FormDescription class="text-xs sm:text-sm">
<span v-if="!isFiat">Amount to request in satoshis</span>
<span v-else-if="satPreviewLoading" class="text-muted-foreground">
Converting…
</span>
<span v-else-if="satPreview">
≈ {{ Math.round(satPreview).toLocaleString() }} sats at the current rate
</span>
<span v-else>
Amount to request in {{ unit }}, converted to sats when the invoice is created
</span>
</FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
</FormField> </FormField>
@ -267,7 +365,12 @@ function formatExpiry(seconds: number): string {
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 sm:gap-4 text-sm"> <div class="grid grid-cols-1 sm:grid-cols-2 gap-2 sm:gap-4 text-sm">
<div class="flex justify-between sm:block"> <div class="flex justify-between sm:block">
<span class="text-muted-foreground">Amount:</span> <span class="text-muted-foreground">Amount:</span>
<span class="font-medium sm:ml-2">{{ createdInvoice.amount }} sats</span> <span class="font-medium sm:ml-2">
<template v-if="createdInvoice.fiatCurrency && createdInvoice.fiatAmount">
{{ formatCurrency(createdInvoice.fiatAmount, createdInvoice.fiatCurrency) }}
</template>
<template v-else>{{ createdInvoice.amount }} sats</template>
</span>
</div> </div>
<div class="flex justify-between sm:block"> <div class="flex justify-between sm:block">
<span class="text-muted-foreground">Status:</span> <span class="text-muted-foreground">Status:</span>
@ -275,6 +378,11 @@ function formatExpiry(seconds: number): string {
</div> </div>
</div> </div>
<div v-if="createdInvoice.fiatCurrency" class="text-xs sm:text-sm text-muted-foreground">
Payable as <span class="font-medium text-foreground">{{ createdInvoice.amount.toLocaleString() }} sats</span>,
fixed at the rate when this invoice was created.
</div>
<div> <div>
<span class="text-muted-foreground text-xs sm:text-sm">Description:</span> <span class="text-muted-foreground text-xs sm:text-sm">Description:</span>
<p class="font-medium text-sm break-words">{{ createdInvoice.memo }}</p> <p class="font-medium text-sm break-words">{{ createdInvoice.memo }}</p>
@ -309,7 +417,13 @@ function formatExpiry(seconds: number): string {
<Check class="h-12 w-12 sm:h-16 sm:w-16 text-green-600 dark:text-green-400 mb-2 sm:mb-4" /> <Check class="h-12 w-12 sm:h-16 sm:w-16 text-green-600 dark:text-green-400 mb-2 sm:mb-4" />
<div class="text-center px-2"> <div class="text-center px-2">
<p class="text-base sm:text-lg font-semibold text-green-800 dark:text-green-200">Payment Received!</p> <p class="text-base sm:text-lg font-semibold text-green-800 dark:text-green-200">Payment Received!</p>
<p class="text-sm text-green-600 dark:text-green-400 mt-1">{{ createdInvoice.amount }} sats</p> <p class="text-sm text-green-600 dark:text-green-400 mt-1">
<template v-if="createdInvoice.fiatCurrency && createdInvoice.fiatAmount">
{{ formatCurrency(createdInvoice.fiatAmount, createdInvoice.fiatCurrency) }}
({{ createdInvoice.amount.toLocaleString() }} sats)
</template>
<template v-else>{{ createdInvoice.amount }} sats</template>
</p>
</div> </div>
</div> </div>
</div> </div>

View file

@ -22,9 +22,16 @@ export interface SendPaymentRequest {
} }
export interface CreateInvoiceRequest { export interface CreateInvoiceRequest {
/** Amount expressed in `unit`. Sats when `unit` is "sat", else fiat major units (e.g. 5.50 EUR). */
amount: number amount: number
memo: string memo: string
expiry?: number // Optional expiry in seconds expiry?: number // Optional expiry in seconds
/**
* Denomination currency. "sat" (default) or an ISO 4217 code the server
* accepts. LNbits converts a fiat amount to sats at creation time using
* its own rate; the result is an ordinary BOLT11 invoice.
*/
unit?: string
} }
export interface Invoice { export interface Invoice {
@ -32,10 +39,16 @@ export interface Invoice {
bolt11: string // The BOLT11 invoice bolt11: string // The BOLT11 invoice
payment_request: string // Same as bolt11, for compatibility payment_request: string // Same as bolt11, for compatibility
checking_id: string checking_id: string
/** Always sats — the settled amount, converted by LNbits when fiat-denominated. */
amount: number amount: number
memo: string memo: string
time: number time: number
expiry: number | null expiry: number | null
/** Set when the invoice was denominated in fiat: the original amount and its currency. */
fiatAmount?: number
fiatCurrency?: string
/** Sats per unit of `fiatCurrency` at creation time, as recorded by LNbits. */
fiatRate?: number
} }
export interface PaymentTransaction { export interface PaymentTransaction {
@ -190,7 +203,9 @@ export default class WalletService extends BaseService {
body: JSON.stringify({ body: JSON.stringify({
out: false, // Incoming payment (receiving) out: false, // Incoming payment (receiving)
amount: request.amount, amount: request.amount,
unit: 'sat', // A fiat unit makes LNbits convert to sats at its own rate and
// record the original denomination in the payment's `extra`.
unit: request.unit || 'sat',
memo: request.memo, memo: request.memo,
expiry: request.expiry || 3600 // Default 1 hour expiry expiry: request.expiry || 3600 // Default 1 hour expiry
}) })
@ -209,7 +224,13 @@ export default class WalletService extends BaseService {
...rawInvoice, ...rawInvoice,
payment_request: rawInvoice.bolt11, // Copy bolt11 to payment_request for compatibility payment_request: rawInvoice.bolt11, // Copy bolt11 to payment_request for compatibility
amount: rawInvoice.amount / 1000, // Convert from millisats to sats amount: rawInvoice.amount / 1000, // Convert from millisats to sats
expiry: rawInvoice.expiry ? this.parseExpiryToSeconds(rawInvoice.expiry) : null expiry: rawInvoice.expiry ? this.parseExpiryToSeconds(rawInvoice.expiry) : null,
// For a fiat-denominated invoice LNbits echoes the original amount,
// currency and rate back on `extra`. Surface them so the UI can show
// what the payer was actually quoted rather than only the sat amount.
fiatAmount: rawInvoice.extra?.fiat_amount,
fiatCurrency: rawInvoice.extra?.fiat_currency,
fiatRate: rawInvoice.extra?.fiat_rate
} }
console.log('Processed invoice:', invoice) console.log('Processed invoice:', invoice)