Compare commits
2 commits
682e1964bc
...
7308292642
| Author | SHA1 | Date | |
|---|---|---|---|
| 7308292642 | |||
| 5ab944bd42 |
4 changed files with 290 additions and 24 deletions
|
|
@ -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 {
|
||||
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">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { toTypedSchema } from '@vee-validate/zod'
|
||||
import * as z from 'zod'
|
||||
|
|
@ -11,8 +11,19 @@ import { Textarea } from '@/components/ui/textarea'
|
|||
import { Label } from '@/components/ui/label'
|
||||
import { QrCode, Copy, Check, Loader2 } from 'lucide-vue-next'
|
||||
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'
|
||||
|
||||
const SAT = 'sat'
|
||||
|
||||
interface Props {
|
||||
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 paymentService = injectService(SERVICE_TOKENS.PAYMENT_SERVICE) as any
|
||||
|
||||
// Form validation schema
|
||||
const formSchema = toTypedSchema(z.object({
|
||||
amount: z.number().min(1, "Amount must be at least 1 sat").max(10000000, "Amount too large"),
|
||||
memo: z.string().max(640, "Description too long").optional()
|
||||
}))
|
||||
// Denomination currency: "sat", or an ISO 4217 code the server accepts.
|
||||
const unit = ref<string>(SAT)
|
||||
const isFiat = computed(() => unit.value !== SAT)
|
||||
|
||||
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
|
||||
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)
|
||||
|
||||
// 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
|
||||
const createdInvoice = ref<Invoice | null>(null)
|
||||
const copiedField = ref<string | null>(null)
|
||||
|
|
@ -96,7 +162,8 @@ const onSubmit = form.handleSubmit(async (formValues) => {
|
|||
try {
|
||||
const invoice = await walletService.createInvoice({
|
||||
amount: formValues.amount,
|
||||
memo: formValues.memo || 'Payment request'
|
||||
memo: formValues.memo || 'Payment request',
|
||||
unit: unit.value
|
||||
// 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">
|
||||
<FormField v-slot="{ componentField }" name="amount">
|
||||
<FormItem>
|
||||
<FormLabel class="text-sm font-medium">Amount (sats) *</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max="10000000"
|
||||
placeholder="Enter amount in sats"
|
||||
class="h-9 sm:h-10"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription class="text-xs sm:text-sm">Amount to request in satoshis</FormDescription>
|
||||
<FormLabel class="text-sm font-medium">Amount *</FormLabel>
|
||||
<div class="flex gap-2">
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
:min="amountMin"
|
||||
:step="amountStep"
|
||||
max="10000000"
|
||||
:placeholder="isFiat ? `Enter amount in ${unit}` : 'Enter amount in sats'"
|
||||
class="h-9 sm:h-10 flex-1"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</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 />
|
||||
</FormItem>
|
||||
</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="flex justify-between sm:block">
|
||||
<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 class="flex justify-between sm:block">
|
||||
<span class="text-muted-foreground">Status:</span>
|
||||
|
|
@ -275,6 +378,11 @@ function formatExpiry(seconds: number): string {
|
|||
</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>
|
||||
<span class="text-muted-foreground text-xs sm:text-sm">Description:</span>
|
||||
<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" />
|
||||
<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-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>
|
||||
|
|
|
|||
|
|
@ -23,9 +23,16 @@ export interface SendPaymentRequest {
|
|||
}
|
||||
|
||||
export interface CreateInvoiceRequest {
|
||||
/** Amount expressed in `unit`. Sats when `unit` is "sat", else fiat major units (e.g. 5.50 EUR). */
|
||||
amount: number
|
||||
memo: string
|
||||
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 {
|
||||
|
|
@ -33,10 +40,16 @@ export interface Invoice {
|
|||
bolt11: string // The BOLT11 invoice
|
||||
payment_request: string // Same as bolt11, for compatibility
|
||||
checking_id: string
|
||||
/** Always sats — the settled amount, converted by LNbits when fiat-denominated. */
|
||||
amount: number
|
||||
memo: string
|
||||
time: number
|
||||
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 {
|
||||
|
|
@ -191,7 +204,9 @@ export default class WalletService extends BaseService {
|
|||
body: JSON.stringify({
|
||||
out: false, // Incoming payment (receiving)
|
||||
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,
|
||||
expiry: request.expiry || 3600 // Default 1 hour expiry
|
||||
})
|
||||
|
|
@ -210,7 +225,13 @@ export default class WalletService extends BaseService {
|
|||
...rawInvoice,
|
||||
payment_request: rawInvoice.bolt11, // Copy bolt11 to payment_request for compatibility
|
||||
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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue