feat: Allow custom memo on pay invoice (#3236)

Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
This commit is contained in:
Tiago Vasconcelos 2025-07-04 09:59:57 +01:00 committed by dni ⚡
parent 9aa2194d94
commit 88cf1ac853
No known key found for this signature in database
GPG key ID: D1F416F29AD26E87
9 changed files with 204 additions and 58 deletions

View file

@ -10,6 +10,7 @@ class CreateLnurl(BaseModel):
comment: Optional[str] = None comment: Optional[str] = None
description: Optional[str] = None description: Optional[str] = None
unit: Optional[str] = None unit: Optional[str] = None
internal_memo: Optional[str] = None
class CreateLnurlAuth(BaseModel): class CreateLnurlAuth(BaseModel):

View file

@ -667,8 +667,36 @@
dense dense
type="textarea" type="textarea"
rows="2" rows="2"
v-model.trim="receive.data.memo" v-model="receive.data.memo"
:label="$t('memo')" :label="$t('memo')"
>
<template v-if="receive.data.internalMemo === null" v-slot:append>
<q-icon
name="add_comment"
@click.stop.prevent="receive.data.internalMemo = ''"
class="cursor-pointer"
></q-icon>
<q-tooltip>
<span v-text="$t('internal_memo')"></span>
</q-tooltip>
</template>
</q-input>
<q-input
v-if="receive.data.internalMemo !== null"
autogrow
filled
dense
v-model="receive.data.internalMemo"
class="q-mb-lg"
:label="$t('internal_memo')"
:hint="$t('internal_memo_hint_receive')"
:rules="[ val => !val || val.length <= 512 || 'Please use maximum 512 characters' ]"
><template v-slot:append>
<q-icon
name="cancel"
@click.stop.prevent="receive.data.internalMemo = null"
class="cursor-pointer"
/> </template
></q-input> ></q-input>
<div v-if="g.user.fiat_providers?.length" class="q-mt-md"> <div v-if="g.user.fiat_providers?.length" class="q-mt-md">
<q-list bordered dense class="rounded-borders"> <q-list bordered dense class="rounded-borders">
@ -848,6 +876,21 @@
</div> </div>
<q-separator></q-separator> <q-separator></q-separator>
<h6 class="text-center" v-text="parse.invoice.description"></h6> <h6 class="text-center" v-text="parse.invoice.description"></h6>
<q-input
autogrow
filled
dense
v-model="parse.data.internalMemo"
:label="$t('internal_memo')"
:hint="$t('internal_memo_hint_pay')"
class="q-mb-lg"
:rules="[ val => !val || val.length <= 512 || 'Please use maximum 512 characters' ]"
><template v-if="parse.data.internalMemo" v-slot:append>
<q-icon
name="cancel"
@click.stop.prevent="parse.data.internalMemo = null"
class="cursor-pointer" /></template
></q-input>
<q-list separator bordered dense class="q-mb-md"> <q-list separator bordered dense class="q-mb-md">
<q-expansion-item expand-separator icon="info" label="Details"> <q-expansion-item expand-separator icon="info" label="Details">
<q-list separator> <q-list separator>
@ -1041,7 +1084,7 @@
</p> </p>
</div> </div>
<div class="row"> <div class="row">
<div class="col"> <div class="col q-mb-lg">
<q-select <q-select
filled filled
dense dense
@ -1075,9 +1118,39 @@
filled filled
dense dense
v-model="parse.data.comment" v-model="parse.data.comment"
:type="parse.lnurlpay.commentAllowed > 64 ? 'textarea' : 'text'" :type="parse.lnurlpay.commentAllowed > 512 ? 'textarea' : 'text'"
label="Comment (optional)" label="Comment (optional)"
:maxlength="parse.lnurlpay.commentAllowed" :maxlength="parse.lnurlpay.commentAllowed"
><template
v-if="parse.data.internalMemo === null"
v-slot:append
>
<q-icon
name="add_comment"
@click.stop.prevent="parse.data.internalMemo = ''"
class="cursor-pointer"
></q-icon>
<q-tooltip>
<span v-text="$t('internal_memo')"></span>
</q-tooltip> </template
></q-input>
<br />
<q-input
v-if="parse.data.internalMemo !== null"
autogrow
filled
dense
v-model="parse.data.internalMemo"
:label="$t('internal_memo')"
:hint="$t('internal_memo_hint_pay')"
class=""
:rules="[ val => !val || val.length <= 512 || 'Please use maximum 512 characters' ]"
><template v-slot:append>
<q-icon
name="cancel"
@click.stop.prevent="parse.data.internalMemo = null"
class="cursor-pointer"
/> </template
></q-input> ></q-input>
</div> </div>
</div> </div>

View file

@ -121,7 +121,6 @@ async def api_payments_counting_stats(
filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)), filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)),
user: User = Depends(check_user_exists), user: User = Depends(check_user_exists),
): ):
if user.admin: if user.admin:
# admin user can see payments from all wallets # admin user can see payments from all wallets
for_user_id = None for_user_id = None
@ -142,7 +141,6 @@ async def api_payments_wallets_stats(
filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)), filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)),
user: User = Depends(check_user_exists), user: User = Depends(check_user_exists),
): ):
if user.admin: if user.admin:
# admin user can see payments from all wallets # admin user can see payments from all wallets
for_user_id = None for_user_id = None
@ -163,7 +161,6 @@ async def api_payments_daily_stats(
user: User = Depends(check_user_exists), user: User = Depends(check_user_exists),
filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)), filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)),
): ):
if user.admin: if user.admin:
# admin user can see payments from all wallets # admin user can see payments from all wallets
for_user_id = None for_user_id = None
@ -285,23 +282,36 @@ async def api_payments_fee_reserve(invoice: str = Query("invoice")) -> JSONRespo
) )
@payment_router.post("/lnurl") def _validate_lnurl_response(
async def api_payments_pay_lnurl( params: dict, domain: str, amount_msat: int
data: CreateLnurl, wallet: WalletTypeInfo = Depends(require_admin_key) ) -> bolt11.Invoice:
) -> Payment: if params.get("status") == "ERROR":
domain = urlparse(data.callback).netloc raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"{domain} said: '{params.get('reason', '')}'",
)
if not params.get("pr"):
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"{domain} did not return a payment request.",
)
invoice = bolt11.decode(params["pr"])
if invoice.amount_msat != amount_msat:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=(
f"{domain} returned an invalid invoice. Expected"
f" {amount_msat} msat, got {invoice.amount_msat}."
),
)
return invoice
async def _fetch_lnurl_params(data: CreateLnurl, amount_msat: int, domain: str) -> dict:
headers = {"User-Agent": settings.user_agent} headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers, follow_redirects=True) as client: async with httpx.AsyncClient(headers=headers, follow_redirects=True) as client:
try: try:
if data.unit and data.unit != "sat": r: httpx.Response = await client.get(
amount_msat = await fiat_amount_as_satoshis(data.amount, data.unit)
# no msat precision
amount_msat = ceil(amount_msat // 1000) * 1000
else:
amount_msat = data.amount
check_callback_url(data.callback)
r = await client.get(
data.callback, data.callback,
params={"amount": amount_msat, "comment": data.comment}, params={"amount": amount_msat, "comment": data.comment},
timeout=40, timeout=40,
@ -315,29 +325,24 @@ async def api_payments_pay_lnurl(
status_code=HTTPStatus.BAD_REQUEST, status_code=HTTPStatus.BAD_REQUEST,
detail=f"Failed to connect to {domain}.", detail=f"Failed to connect to {domain}.",
) from exc ) from exc
return json.loads(r.text)
params = json.loads(r.text)
if params.get("status") == "ERROR":
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"{domain} said: '{params.get('reason', '')}'",
)
if not params.get("pr"): @payment_router.post("/lnurl")
raise HTTPException( async def api_payments_pay_lnurl(
status_code=HTTPStatus.BAD_REQUEST, data: CreateLnurl, wallet: WalletTypeInfo = Depends(require_admin_key)
detail=f"{domain} did not return a payment request.", ) -> Payment:
) domain = urlparse(data.callback).netloc
check_callback_url(data.callback)
invoice = bolt11.decode(params["pr"]) if data.unit and data.unit != "sat":
if invoice.amount_msat != amount_msat: amount_msat = await fiat_amount_as_satoshis(data.amount, data.unit)
raise HTTPException( amount_msat = ceil(amount_msat // 1000) * 1000
status_code=HTTPStatus.BAD_REQUEST, else:
detail=( amount_msat = data.amount
f"{domain} returned an invalid invoice. Expected"
f" {amount_msat} msat, got {invoice.amount_msat}." params = await _fetch_lnurl_params(data, amount_msat, domain)
), _validate_lnurl_response(params, domain, amount_msat)
)
extra = {} extra = {}
if params.get("successAction"): if params.get("successAction"):
@ -347,6 +352,11 @@ async def api_payments_pay_lnurl(
if data.unit and data.unit != "sat": if data.unit and data.unit != "sat":
extra["fiat_currency"] = data.unit extra["fiat_currency"] = data.unit
extra["fiat_amount"] = data.amount / 1000 extra["fiat_amount"] = data.amount / 1000
if data.internal_memo is not None:
assert (
len(data.internal_memo) <= 512
), "Internal memo must be 512 characters or less."
extra["internal_memo"] = data.internal_memo
assert data.description is not None, "description is required" assert data.description is not None, "description is required"
payment = await pay_invoice( payment = await pay_invoice(

File diff suppressed because one or more lines are too long

View file

@ -101,6 +101,11 @@ window.localisation.en = {
memo: 'Memo', memo: 'Memo',
date: 'Date', date: 'Date',
path: 'Path', path: 'Path',
internal_memo: 'Internal memo (optional)',
internal_memo_hint_receive:
"This memo is not shown to the payer but it's stored in the invoice for your reference.",
internal_memo_hint_pay:
"This memo is not shown to the payee but it's stored in the payment for your reference.",
payment_processing: 'Processing payment...', payment_processing: 'Processing payment...',
payment_processing: 'Processing payment...', payment_processing: 'Processing payment...',
payment_successful: 'Payment successful!', payment_successful: 'Payment successful!',

View file

@ -20,22 +20,35 @@ window.LNbits = {
memo, memo,
unit = 'sat', unit = 'sat',
lnurlCallback = null, lnurlCallback = null,
fiatProvider = null fiatProvider = null,
internalMemo = null
) { ) {
return this.request('post', '/api/v1/payments', wallet.inkey, { const data = {
out: false, out: false,
amount: amount, amount: amount,
memo: memo, memo: memo,
lnurl_callback: lnurlCallback,
unit: unit, unit: unit,
lnurl_callback: lnurlCallback,
fiat_provider: fiatProvider fiat_provider: fiatProvider
}) }
if (internalMemo) {
data.extra = {
internal_memo: String(internalMemo)
}
}
return this.request('post', '/api/v1/payments', wallet.inkey, data)
}, },
payInvoice(wallet, bolt11) { payInvoice(wallet, bolt11, internalMemo = null) {
return this.request('post', '/api/v1/payments', wallet.adminkey, { const data = {
out: true, out: true,
bolt11: bolt11 bolt11: bolt11
}) }
if (internalMemo) {
data.extra = {
internal_memo: String(internalMemo)
}
}
return this.request('post', '/api/v1/payments', wallet.adminkey, data)
}, },
payLnurl( payLnurl(
wallet, wallet,
@ -44,16 +57,28 @@ window.LNbits = {
amount, amount,
description = '', description = '',
comment = '', comment = '',
unit = '' unit = '',
internalMemo = null
) { ) {
return this.request('post', '/api/v1/payments/lnurl', wallet.adminkey, { const data = {
callback, callback,
description_hash, description_hash,
amount, amount,
comment, comment,
description, description,
unit unit
}) }
if (internalMemo) {
data.internal_memo = String(internalMemo)
}
return this.request(
'post',
'/api/v1/payments/lnurl',
wallet.adminkey,
data
)
}, },
authLnurl(wallet, callback) { authLnurl(wallet, callback) {
return this.request('post', '/api/v1/lnurlauth', wallet.adminkey, { return this.request('post', '/api/v1/lnurlauth', wallet.adminkey, {

View file

@ -8,7 +8,8 @@ window.PaymentsPageLogic = {
searchData: { searchData: {
wallet_id: null, wallet_id: null,
payment_hash: null, payment_hash: null,
memo: null memo: null,
internal_memo: null
}, },
statusFilters: { statusFilters: {
success: true, success: true,
@ -82,6 +83,14 @@ window.PaymentsPageLogic = {
sortable: false, sortable: false,
max_length: 20 max_length: 20
}, },
{
name: 'internal_memo',
align: 'left',
label: 'Internal Memo',
field: 'internal_memo',
sortable: false,
max_length: 20
},
{ {
name: 'wallet_id', name: 'wallet_id',
align: 'left', align: 'left',
@ -166,6 +175,9 @@ window.PaymentsPageLogic = {
p.extra.wallet_fiat_currency p.extra.wallet_fiat_currency
) )
} }
if (p.extra?.internal_memo) {
p.internal_memo = p.extra.internal_memo
}
p.fee_sats = p.fee_sats =
new Intl.NumberFormat(window.LOCALE).format(p.fee / 1000) + ' sats' new Intl.NumberFormat(window.LOCALE).format(p.fee / 1000) + ' sats'

View file

@ -14,6 +14,7 @@ window.WalletPageLogic = {
request: '', request: '',
amount: 0, amount: 0,
comment: '', comment: '',
internalMemo: null,
unit: 'sat' unit: 'sat'
}, },
paymentChecker: null, paymentChecker: null,
@ -38,7 +39,8 @@ window.WalletPageLogic = {
fiatProvider: '', fiatProvider: '',
data: { data: {
amount: null, amount: null,
memo: '' memo: '',
internalMemo: null
} }
}, },
invoiceQrCode: '', invoiceQrCode: '',
@ -197,6 +199,7 @@ window.WalletPageLogic = {
this.receive.paymentHash = null this.receive.paymentHash = null
this.receive.data.amount = null this.receive.data.amount = null
this.receive.data.memo = null this.receive.data.memo = null
this.receive.data.internalMemo = null
this.receive.unit = this.isFiatPriority this.receive.unit = this.isFiatPriority
? this.g.wallet.currency || 'sat' ? this.g.wallet.currency || 'sat'
: 'sat' : 'sat'
@ -218,6 +221,7 @@ window.WalletPageLogic = {
window.isSecureContext && navigator.clipboard?.readText !== undefined window.isSecureContext && navigator.clipboard?.readText !== undefined
this.parse.data.request = '' this.parse.data.request = ''
this.parse.data.comment = '' this.parse.data.comment = ''
this.parse.data.internalMemo = null
this.parse.data.paymentChecker = null this.parse.data.paymentChecker = null
this.parse.camera.show = false this.parse.camera.show = false
this.focusInput('textArea') this.focusInput('textArea')
@ -253,7 +257,8 @@ window.WalletPageLogic = {
this.receive.data.memo, this.receive.data.memo,
this.receive.unit, this.receive.unit,
this.receive.lnurl && this.receive.lnurl.callback, this.receive.lnurl && this.receive.lnurl.callback,
this.receive.fiatProvider this.receive.fiatProvider,
this.receive.data.internalMemo
) )
.then(response => { .then(response => {
this.g.updatePayments = !this.g.updatePayments this.g.updatePayments = !this.g.updatePayments
@ -477,7 +482,11 @@ window.WalletPageLogic = {
}) })
LNbits.api LNbits.api
.payInvoice(this.g.wallet, this.parse.data.request) .payInvoice(
this.g.wallet,
this.parse.data.request,
this.parse.data.internalMemo
)
.then(response => { .then(response => {
dismissPaymentMsg() dismissPaymentMsg()
this.updatePayments = !this.updatePayments this.updatePayments = !this.updatePayments
@ -515,7 +524,8 @@ window.WalletPageLogic = {
this.parse.data.amount * 1000, this.parse.data.amount * 1000,
this.parse.lnurlpay.description.slice(0, 120), this.parse.lnurlpay.description.slice(0, 120),
this.parse.data.comment, this.parse.data.comment,
this.parse.data.unit this.parse.data.unit,
this.parse.data.internalMemo
) )
.then(response => { .then(response => {
this.parse.show = false this.parse.show = false

View file

@ -880,14 +880,24 @@
:props="props" :props="props"
style="white-space: normal; word-break: break-all" style="white-space: normal; word-break: break-all"
> >
<q-badge v-if="props.row.tag" color="yellow" text-color="black"> <q-badge
v-if="props.row.tag"
color="yellow"
text-color="black"
class="q-mr-sm"
>
<a <a
v-text="'#' + props.row.tag" v-text="'#' + props.row.tag"
class="inherit" class="inherit"
:href="['/', props.row.tag].join('')" :href="['/', props.row.tag].join('')"
></a> ></a>
</q-badge> </q-badge>
<span class="q-ml-sm" v-text="props.row.memo"></span> <span v-text="props.row.memo"></span>
<span
class="text-grey-5 q-ml-sm ellipsis"
v-if="props.row.extra.internal_memo"
v-text="`(${props.row.extra.internal_memo})`"
></span>
<br /> <br />
<i> <i>