feat(wallet): denominate invoices in any currency the server accepts #166
4 changed files with 290 additions and 24 deletions
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.
commit
5ab944bd42
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
110
src/modules/base/composables/useCurrencies.ts
Normal file
110
src/modules/base/composables/useCurrencies.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
<FormControl>
|
<div class="flex gap-2">
|
||||||
<Input
|
<FormControl>
|
||||||
type="number"
|
<Input
|
||||||
min="1"
|
type="number"
|
||||||
max="10000000"
|
:min="amountMin"
|
||||||
placeholder="Enter amount in sats"
|
:step="amountStep"
|
||||||
class="h-9 sm:h-10"
|
max="10000000"
|
||||||
v-bind="componentField"
|
:placeholder="isFiat ? `Enter amount in ${unit}` : 'Enter amount in sats'"
|
||||||
/>
|
class="h-9 sm:h-10 flex-1"
|
||||||
</FormControl>
|
v-bind="componentField"
|
||||||
<FormDescription class="text-xs sm:text-sm">Amount to request in satoshis</FormDescription>
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<!-- 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>
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue