feat: Allow custom memo on pay invoice (#3236)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
This commit is contained in:
parent
9aa2194d94
commit
88cf1ac853
9 changed files with 204 additions and 58 deletions
|
|
@ -10,6 +10,7 @@ class CreateLnurl(BaseModel):
|
|||
comment: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
unit: Optional[str] = None
|
||||
internal_memo: Optional[str] = None
|
||||
|
||||
|
||||
class CreateLnurlAuth(BaseModel):
|
||||
|
|
|
|||
|
|
@ -667,8 +667,36 @@
|
|||
dense
|
||||
type="textarea"
|
||||
rows="2"
|
||||
v-model.trim="receive.data.memo"
|
||||
v-model="receive.data.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>
|
||||
<div v-if="g.user.fiat_providers?.length" class="q-mt-md">
|
||||
<q-list bordered dense class="rounded-borders">
|
||||
|
|
@ -848,6 +876,21 @@
|
|||
</div>
|
||||
<q-separator></q-separator>
|
||||
<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-expansion-item expand-separator icon="info" label="Details">
|
||||
<q-list separator>
|
||||
|
|
@ -1041,7 +1084,7 @@
|
|||
</p>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<div class="col q-mb-lg">
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
|
|
@ -1075,9 +1118,39 @@
|
|||
filled
|
||||
dense
|
||||
v-model="parse.data.comment"
|
||||
:type="parse.lnurlpay.commentAllowed > 64 ? 'textarea' : 'text'"
|
||||
:type="parse.lnurlpay.commentAllowed > 512 ? 'textarea' : 'text'"
|
||||
label="Comment (optional)"
|
||||
: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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -121,7 +121,6 @@ async def api_payments_counting_stats(
|
|||
filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)),
|
||||
user: User = Depends(check_user_exists),
|
||||
):
|
||||
|
||||
if user.admin:
|
||||
# admin user can see payments from all wallets
|
||||
for_user_id = None
|
||||
|
|
@ -142,7 +141,6 @@ async def api_payments_wallets_stats(
|
|||
filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)),
|
||||
user: User = Depends(check_user_exists),
|
||||
):
|
||||
|
||||
if user.admin:
|
||||
# admin user can see payments from all wallets
|
||||
for_user_id = None
|
||||
|
|
@ -163,7 +161,6 @@ async def api_payments_daily_stats(
|
|||
user: User = Depends(check_user_exists),
|
||||
filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)),
|
||||
):
|
||||
|
||||
if user.admin:
|
||||
# admin user can see payments from all wallets
|
||||
for_user_id = None
|
||||
|
|
@ -285,23 +282,36 @@ async def api_payments_fee_reserve(invoice: str = Query("invoice")) -> JSONRespo
|
|||
)
|
||||
|
||||
|
||||
@payment_router.post("/lnurl")
|
||||
async def api_payments_pay_lnurl(
|
||||
data: CreateLnurl, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||
) -> Payment:
|
||||
domain = urlparse(data.callback).netloc
|
||||
def _validate_lnurl_response(
|
||||
params: dict, domain: str, amount_msat: int
|
||||
) -> bolt11.Invoice:
|
||||
if params.get("status") == "ERROR":
|
||||
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}
|
||||
async with httpx.AsyncClient(headers=headers, follow_redirects=True) as client:
|
||||
try:
|
||||
if data.unit and data.unit != "sat":
|
||||
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(
|
||||
r: httpx.Response = await client.get(
|
||||
data.callback,
|
||||
params={"amount": amount_msat, "comment": data.comment},
|
||||
timeout=40,
|
||||
|
|
@ -315,29 +325,24 @@ async def api_payments_pay_lnurl(
|
|||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=f"Failed to connect to {domain}.",
|
||||
) 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"):
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=f"{domain} did not return a payment request.",
|
||||
)
|
||||
@payment_router.post("/lnurl")
|
||||
async def api_payments_pay_lnurl(
|
||||
data: CreateLnurl, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||
) -> Payment:
|
||||
domain = urlparse(data.callback).netloc
|
||||
check_callback_url(data.callback)
|
||||
|
||||
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}."
|
||||
),
|
||||
)
|
||||
if data.unit and data.unit != "sat":
|
||||
amount_msat = await fiat_amount_as_satoshis(data.amount, data.unit)
|
||||
amount_msat = ceil(amount_msat // 1000) * 1000
|
||||
else:
|
||||
amount_msat = data.amount
|
||||
|
||||
params = await _fetch_lnurl_params(data, amount_msat, domain)
|
||||
_validate_lnurl_response(params, domain, amount_msat)
|
||||
|
||||
extra = {}
|
||||
if params.get("successAction"):
|
||||
|
|
@ -347,6 +352,11 @@ async def api_payments_pay_lnurl(
|
|||
if data.unit and data.unit != "sat":
|
||||
extra["fiat_currency"] = data.unit
|
||||
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"
|
||||
|
||||
payment = await pay_invoice(
|
||||
|
|
|
|||
4
lnbits/static/bundle.min.js
vendored
4
lnbits/static/bundle.min.js
vendored
File diff suppressed because one or more lines are too long
|
|
@ -101,6 +101,11 @@ window.localisation.en = {
|
|||
memo: 'Memo',
|
||||
date: 'Date',
|
||||
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_successful: 'Payment successful!',
|
||||
|
|
|
|||
|
|
@ -20,22 +20,35 @@ window.LNbits = {
|
|||
memo,
|
||||
unit = 'sat',
|
||||
lnurlCallback = null,
|
||||
fiatProvider = null
|
||||
fiatProvider = null,
|
||||
internalMemo = null
|
||||
) {
|
||||
return this.request('post', '/api/v1/payments', wallet.inkey, {
|
||||
const data = {
|
||||
out: false,
|
||||
amount: amount,
|
||||
memo: memo,
|
||||
lnurl_callback: lnurlCallback,
|
||||
unit: unit,
|
||||
lnurl_callback: lnurlCallback,
|
||||
fiat_provider: fiatProvider
|
||||
})
|
||||
}
|
||||
if (internalMemo) {
|
||||
data.extra = {
|
||||
internal_memo: String(internalMemo)
|
||||
}
|
||||
}
|
||||
return this.request('post', '/api/v1/payments', wallet.inkey, data)
|
||||
},
|
||||
payInvoice(wallet, bolt11) {
|
||||
return this.request('post', '/api/v1/payments', wallet.adminkey, {
|
||||
payInvoice(wallet, bolt11, internalMemo = null) {
|
||||
const data = {
|
||||
out: true,
|
||||
bolt11: bolt11
|
||||
})
|
||||
}
|
||||
if (internalMemo) {
|
||||
data.extra = {
|
||||
internal_memo: String(internalMemo)
|
||||
}
|
||||
}
|
||||
return this.request('post', '/api/v1/payments', wallet.adminkey, data)
|
||||
},
|
||||
payLnurl(
|
||||
wallet,
|
||||
|
|
@ -44,16 +57,28 @@ window.LNbits = {
|
|||
amount,
|
||||
description = '',
|
||||
comment = '',
|
||||
unit = ''
|
||||
unit = '',
|
||||
internalMemo = null
|
||||
) {
|
||||
return this.request('post', '/api/v1/payments/lnurl', wallet.adminkey, {
|
||||
const data = {
|
||||
callback,
|
||||
description_hash,
|
||||
amount,
|
||||
comment,
|
||||
description,
|
||||
unit
|
||||
})
|
||||
}
|
||||
|
||||
if (internalMemo) {
|
||||
data.internal_memo = String(internalMemo)
|
||||
}
|
||||
|
||||
return this.request(
|
||||
'post',
|
||||
'/api/v1/payments/lnurl',
|
||||
wallet.adminkey,
|
||||
data
|
||||
)
|
||||
},
|
||||
authLnurl(wallet, callback) {
|
||||
return this.request('post', '/api/v1/lnurlauth', wallet.adminkey, {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@ window.PaymentsPageLogic = {
|
|||
searchData: {
|
||||
wallet_id: null,
|
||||
payment_hash: null,
|
||||
memo: null
|
||||
memo: null,
|
||||
internal_memo: null
|
||||
},
|
||||
statusFilters: {
|
||||
success: true,
|
||||
|
|
@ -82,6 +83,14 @@ window.PaymentsPageLogic = {
|
|||
sortable: false,
|
||||
max_length: 20
|
||||
},
|
||||
{
|
||||
name: 'internal_memo',
|
||||
align: 'left',
|
||||
label: 'Internal Memo',
|
||||
field: 'internal_memo',
|
||||
sortable: false,
|
||||
max_length: 20
|
||||
},
|
||||
{
|
||||
name: 'wallet_id',
|
||||
align: 'left',
|
||||
|
|
@ -166,6 +175,9 @@ window.PaymentsPageLogic = {
|
|||
p.extra.wallet_fiat_currency
|
||||
)
|
||||
}
|
||||
if (p.extra?.internal_memo) {
|
||||
p.internal_memo = p.extra.internal_memo
|
||||
}
|
||||
p.fee_sats =
|
||||
new Intl.NumberFormat(window.LOCALE).format(p.fee / 1000) + ' sats'
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ window.WalletPageLogic = {
|
|||
request: '',
|
||||
amount: 0,
|
||||
comment: '',
|
||||
internalMemo: null,
|
||||
unit: 'sat'
|
||||
},
|
||||
paymentChecker: null,
|
||||
|
|
@ -38,7 +39,8 @@ window.WalletPageLogic = {
|
|||
fiatProvider: '',
|
||||
data: {
|
||||
amount: null,
|
||||
memo: ''
|
||||
memo: '',
|
||||
internalMemo: null
|
||||
}
|
||||
},
|
||||
invoiceQrCode: '',
|
||||
|
|
@ -197,6 +199,7 @@ window.WalletPageLogic = {
|
|||
this.receive.paymentHash = null
|
||||
this.receive.data.amount = null
|
||||
this.receive.data.memo = null
|
||||
this.receive.data.internalMemo = null
|
||||
this.receive.unit = this.isFiatPriority
|
||||
? this.g.wallet.currency || 'sat'
|
||||
: 'sat'
|
||||
|
|
@ -218,6 +221,7 @@ window.WalletPageLogic = {
|
|||
window.isSecureContext && navigator.clipboard?.readText !== undefined
|
||||
this.parse.data.request = ''
|
||||
this.parse.data.comment = ''
|
||||
this.parse.data.internalMemo = null
|
||||
this.parse.data.paymentChecker = null
|
||||
this.parse.camera.show = false
|
||||
this.focusInput('textArea')
|
||||
|
|
@ -253,7 +257,8 @@ window.WalletPageLogic = {
|
|||
this.receive.data.memo,
|
||||
this.receive.unit,
|
||||
this.receive.lnurl && this.receive.lnurl.callback,
|
||||
this.receive.fiatProvider
|
||||
this.receive.fiatProvider,
|
||||
this.receive.data.internalMemo
|
||||
)
|
||||
.then(response => {
|
||||
this.g.updatePayments = !this.g.updatePayments
|
||||
|
|
@ -477,7 +482,11 @@ window.WalletPageLogic = {
|
|||
})
|
||||
|
||||
LNbits.api
|
||||
.payInvoice(this.g.wallet, this.parse.data.request)
|
||||
.payInvoice(
|
||||
this.g.wallet,
|
||||
this.parse.data.request,
|
||||
this.parse.data.internalMemo
|
||||
)
|
||||
.then(response => {
|
||||
dismissPaymentMsg()
|
||||
this.updatePayments = !this.updatePayments
|
||||
|
|
@ -515,7 +524,8 @@ window.WalletPageLogic = {
|
|||
this.parse.data.amount * 1000,
|
||||
this.parse.lnurlpay.description.slice(0, 120),
|
||||
this.parse.data.comment,
|
||||
this.parse.data.unit
|
||||
this.parse.data.unit,
|
||||
this.parse.data.internalMemo
|
||||
)
|
||||
.then(response => {
|
||||
this.parse.show = false
|
||||
|
|
|
|||
|
|
@ -880,14 +880,24 @@
|
|||
:props="props"
|
||||
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
|
||||
v-text="'#' + props.row.tag"
|
||||
class="inherit"
|
||||
:href="['/', props.row.tag].join('')"
|
||||
></a>
|
||||
</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 />
|
||||
|
||||
<i>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue